Add conditional Entry training probe

This commit is contained in:
Codex
2026-06-28 08:33:49 +08:00
parent 7268f640a6
commit 0323fb3caf
3 changed files with 416 additions and 0 deletions
@@ -0,0 +1,34 @@
from __future__ import annotations
import argparse
import _bootstrap # noqa: F401
from trader_training.conditional_entry_probe import probe_conditional_entry_training
from trader_training.io_utils import add_common_args, setup_logging
def _float_tuple(value: str) -> tuple[float, ...]:
return tuple(float(item.strip()) for item in value.split(",") if item.strip())
def _str_tuple(value: str) -> tuple[str, ...]:
return tuple(item.strip() for item in value.split(",") if item.strip())
def main() -> None:
parser = argparse.ArgumentParser()
add_common_args(parser)
parser.add_argument("--condition-opportunity-bps", type=_float_tuple, default=(6.0, 12.0, 20.0, 40.0, 60.0))
parser.add_argument("--target-edge-bps", type=_float_tuple, default=(0.0, 3.0))
parser.add_argument("--model-families", type=_str_tuple, default=("linear", "tree"))
parser.add_argument("--top-fractions", type=_float_tuple, default=(0.01, 0.02, 0.05, 0.10))
parser.add_argument("--max-train-rows", type=int, default=0)
parser.add_argument("--min-train-rows", type=int, default=1000)
parser.add_argument("--min-eval-rows", type=int, default=500)
args = parser.parse_args()
setup_logging()
probe_conditional_entry_training(args)
if __name__ == "__main__":
main()
+45
View File
@@ -14,6 +14,7 @@ if str(TRAINING_ROOT) not in sys.path:
sys.path.insert(0, str(TRAINING_ROOT))
from trader_training.onnx_export import LinearHead, export_heads
from trader_training.conditional_entry_probe import probe_conditional_entry_training
from trader_training.dynamic_exit_search import search_dynamic_exit_plans
from trader_training.entry_condition_pair_screen import screen_entry_condition_pairs
from trader_training.entry_feature_screen import _bucket_edges, _screen_edge_column
@@ -163,6 +164,50 @@ class TrainingContractTest(unittest.TestCase):
self.assertEqual("LONG", best["side"])
self.assertTrue(bool(best["stable_top_edge_positive"]))
def test_conditional_entry_probe_finds_positive_oracle_direction_subset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
data_root = Path(tmp)
run_root = data_root / "trader-v4" / "runs" / "unit-conditional-entry"
dataset_path = run_root / "dataset" / "entry_train.parquet"
dataset_path.parent.mkdir(parents=True)
frames = []
row_count = 900
base_feature_values = np.linspace(0.0, 0.999, row_count)
for split_id in TRAINING_SPLITS:
frame = pd.DataFrame({feature: 0.0 for feature in FEATURE_ORDER}, index=np.arange(row_count))
frame["split_id"] = split_id
frame["ret_1m_bps"] = base_feature_values
good_mask = frame["ret_1m_bps"] > 0.85
opportunity_mask = frame["ret_1m_bps"] > 0.50
frame["long_actual_plan_net_edge_bps"] = np.where(good_mask, 10.0, -6.0)
frame["short_actual_plan_net_edge_bps"] = -6.0
frame["long_max_achievable_net_edge_bps"] = np.where(opportunity_mask, 40.0, 2.0)
frame["short_max_achievable_net_edge_bps"] = 2.0
frames.append(frame)
pd.concat(frames, ignore_index=True).to_parquet(dataset_path, index=False)
probe_conditional_entry_training(
Namespace(
data_root=data_root,
run_id="unit-conditional-entry",
condition_opportunity_bps=(20.0,),
target_edge_bps=(0.0,),
model_families=("linear",),
top_fractions=(0.10,),
max_train_rows=0,
min_train_rows=50,
min_eval_rows=50,
)
)
result = read_json(run_root / "diagnostics" / "conditional_entry_probe_result.json")
candidates = pd.read_csv(run_root / "diagnostics" / "conditional_entry_probe_candidates.csv")
self.assertGreater(result["stable_positive_count"], 0)
self.assertTrue(candidates.iloc[0]["stable_positive"])
self.assertGreater(float(candidates.iloc[0]["min_top_edge_bps"]), 0.0)
def test_dynamic_exit_search_writes_plan_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
data_root = Path(tmp)
@@ -0,0 +1,337 @@
from __future__ import annotations
import logging
from typing import Any
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, roc_auc_score
from sklearn.preprocessing import StandardScaler
from trader_training.entry_feature_screen import _markdown_table
from trader_training.io_utils import read_parquet, run_root, write_json, write_text
from trader_training.schemas import FEATURE_ORDER, FIT_SPLIT, LATEST_STRESS_SPLIT, TUNE_SPLIT, VALIDATION_LOCKED_SPLIT
EVAL_SPLITS = (TUNE_SPLIT, VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT)
def probe_conditional_entry_training(args: Any) -> None:
root = run_root(args)
dataset = read_parquet(root / "dataset" / "entry_train.parquet")
_require_columns(dataset)
condition_opportunities = tuple(float(item) for item in (args.condition_opportunity_bps or (6.0, 12.0, 20.0, 40.0, 60.0)))
target_edges = tuple(float(item) for item in (args.target_edge_bps or (0.0, 3.0)))
model_families = tuple(str(item).strip().lower() for item in (args.model_families or ("linear", "tree")) if str(item).strip())
top_fractions = tuple(float(item) for item in (args.top_fractions or (0.01, 0.02, 0.05, 0.10)))
max_train_rows = int(args.max_train_rows or 0)
rows: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for side in ("LONG", "SHORT"):
prefix = side.lower()
actual_edge_col = f"{prefix}_actual_plan_net_edge_bps"
opportunity_col = f"{prefix}_max_achievable_net_edge_bps"
for condition_opportunity_bps in condition_opportunities:
fit_condition = dataset["split_id"].eq(FIT_SPLIT) & (pd.to_numeric(dataset[opportunity_col], errors="coerce") >= condition_opportunity_bps)
fit_frame = dataset.loc[fit_condition].copy()
if max_train_rows > 0 and len(fit_frame) > max_train_rows:
fit_frame = fit_frame.sort_values("event_time").tail(max_train_rows).copy() if "event_time" in fit_frame.columns else fit_frame.tail(max_train_rows).copy()
if len(fit_frame) < int(args.min_train_rows or 1000):
skipped.append(
{
"side": side,
"condition_opportunity_bps": condition_opportunity_bps,
"reason": "NOT_ENOUGH_TRAIN_ROWS",
"train_rows": int(len(fit_frame)),
}
)
continue
x_train = _x(fit_frame)
for target_edge_bps in target_edges:
y_train = (pd.to_numeric(fit_frame[actual_edge_col], errors="coerce") >= target_edge_bps).astype(int).to_numpy()
if len(np.unique(y_train)) < 2:
skipped.append(
{
"side": side,
"condition_opportunity_bps": condition_opportunity_bps,
"target_edge_bps": target_edge_bps,
"reason": "ONE_CLASS_TRAIN",
"train_rows": int(len(fit_frame)),
"train_positive_rate": float(y_train.mean()) if len(y_train) else 0.0,
}
)
continue
for model_family in model_families:
model, scaler = _fit_model(model_family, x_train, y_train)
for split_id in EVAL_SPLITS:
eval_condition = dataset["split_id"].eq(split_id) & (pd.to_numeric(dataset[opportunity_col], errors="coerce") >= condition_opportunity_bps)
eval_frame = dataset.loc[eval_condition].copy()
if len(eval_frame) < int(args.min_eval_rows or 500):
continue
y_true = (pd.to_numeric(eval_frame[actual_edge_col], errors="coerce") >= target_edge_bps).astype(int).to_numpy()
proba = _predict(model_family, model, scaler, _x(eval_frame))
for top_fraction in top_fractions:
rows.append(
_metric_row(
eval_frame,
y_true,
proba,
side,
model_family,
split_id,
condition_opportunity_bps,
target_edge_bps,
top_fraction,
actual_edge_col,
float(y_train.mean()),
len(fit_frame),
)
)
logging.info(
"trader.training.conditional_entry_probe_fitted side=%s conditionOpportunityBps=%s targetEdgeBps=%s modelFamily=%s trainRows=%s trainPositiveRate=%.6f",
side,
condition_opportunity_bps,
target_edge_bps,
model_family,
len(fit_frame),
float(y_train.mean()),
)
metrics = pd.DataFrame(rows)
candidates = _select_candidates(metrics)
result = {
"run_id": args.run_id,
"purpose": "diagnostic_only_not_exported",
"warning": "condition_opportunity_bps is an oracle future filter; use this only to decide whether conditional Entry training is worth implementing",
"feature_count": len(FEATURE_ORDER),
"condition_opportunity_bps": list(condition_opportunities),
"target_edge_bps": list(target_edges),
"model_families": list(model_families),
"top_fractions": list(top_fractions),
"max_train_rows": max_train_rows,
"metric_count": int(len(metrics)),
"candidate_count": int(len(candidates)),
"stable_positive_count": int(candidates["stable_positive"].sum()) if not candidates.empty else 0,
"skipped": skipped,
}
out_dir = root / "diagnostics"
write_json(out_dir / "conditional_entry_probe_result.json", result)
write_text(out_dir / "conditional_entry_probe_metrics.csv", metrics.to_csv(index=False))
write_text(out_dir / "conditional_entry_probe_candidates.csv", candidates.to_csv(index=False))
write_text(out_dir / "conditional_entry_probe_report.md", _markdown_report(result, candidates))
logging.info(
"trader.training.conditional_entry_probe_written runId=%s metricCount=%s candidateCount=%s stablePositiveCount=%s reportPath=%s",
args.run_id,
len(metrics),
len(candidates),
result["stable_positive_count"],
out_dir / "conditional_entry_probe_report.md",
)
def _require_columns(dataset: pd.DataFrame) -> None:
required = {"split_id", *FEATURE_ORDER}
for side in ("long", "short"):
required.update({f"{side}_actual_plan_net_edge_bps", f"{side}_max_achievable_net_edge_bps"})
missing = sorted(required.difference(dataset.columns))
if missing:
raise ValueError(f"conditional entry probe missing required columns: {missing}")
def _x(frame: pd.DataFrame) -> np.ndarray:
values = frame[FEATURE_ORDER].apply(pd.to_numeric, errors="coerce").replace([np.inf, -np.inf], np.nan).astype("float32")
if values.isna().any().any():
missing = values.columns[values.isna().any()].tolist()
raise ValueError(f"conditional entry probe found non-finite feature values: {missing}")
return values.to_numpy(dtype="float32")
def _fit_model(model_family: str, x_train: np.ndarray, y_train: np.ndarray) -> tuple[Any, StandardScaler | None]:
if model_family == "linear":
scaler = StandardScaler()
x_scaled = scaler.fit_transform(x_train)
model = LogisticRegression(max_iter=500, class_weight="balanced")
model.fit(x_scaled, y_train)
return model, scaler
if model_family == "tree":
model = HistGradientBoostingClassifier(
max_iter=120,
learning_rate=0.04,
max_leaf_nodes=31,
l2_regularization=0.02,
early_stopping=True,
random_state=31,
)
model.fit(x_train, y_train)
return model, None
raise ValueError(f"unsupported model family: {model_family}")
def _predict(model_family: str, model: Any, scaler: StandardScaler | None, x: np.ndarray) -> np.ndarray:
if model_family == "linear":
if scaler is None:
raise ValueError("linear model missing scaler")
return model.predict_proba(scaler.transform(x))[:, 1]
return model.predict_proba(x)[:, 1]
def _metric_row(
frame: pd.DataFrame,
y_true: np.ndarray,
proba: np.ndarray,
side: str,
model_family: str,
split_id: str,
condition_opportunity_bps: float,
target_edge_bps: float,
top_fraction: float,
actual_edge_col: str,
train_positive_rate: float,
train_rows: int,
) -> dict[str, Any]:
order = np.argsort(-proba)
top_n = max(1, int(len(frame) * top_fraction))
top = frame.iloc[order[:top_n]]
constant = np.full(len(y_true), np.clip(train_positive_rate, 1e-6, 1 - 1e-6))
row: dict[str, Any] = {
"side": side,
"model_family": model_family,
"split_id": split_id,
"condition_opportunity_bps": condition_opportunity_bps,
"target_edge_bps": target_edge_bps,
"top_fraction": top_fraction,
"train_rows": int(train_rows),
"train_positive_rate": train_positive_rate,
"row_count": int(len(frame)),
"positive_rate": float(y_true.mean()) if len(y_true) else 0.0,
"brier": float(brier_score_loss(y_true, proba)) if len(y_true) else 0.0,
"constant_brier": float(brier_score_loss(y_true, constant)) if len(y_true) else 0.0,
"top_rows": int(len(top)),
"top_positive_rate": float((top[actual_edge_col] >= target_edge_bps).mean()),
"all_actual_edge_bps": float(frame[actual_edge_col].mean()),
"top_actual_edge_bps": float(top[actual_edge_col].mean()),
"top_probability_min": float(proba[order[:top_n]].min()) if len(proba) else 0.0,
"top_probability_max": float(proba[order[:top_n]].max()) if len(proba) else 0.0,
}
row["auc"] = float(roc_auc_score(y_true, proba)) if len(np.unique(y_true)) == 2 else np.nan
row["top_edge_lift_bps"] = row["top_actual_edge_bps"] - row["all_actual_edge_bps"]
row["brier_beats_constant"] = bool(row["brier"] < row["constant_brier"])
return row
def _select_candidates(metrics: pd.DataFrame) -> pd.DataFrame:
if metrics.empty:
return pd.DataFrame()
key_columns = ["side", "model_family", "condition_opportunity_bps", "target_edge_bps", "top_fraction"]
tune = metrics[metrics["split_id"].eq(TUNE_SPLIT)].copy()
candidates = tune[
key_columns
+ [
"train_rows",
"train_positive_rate",
"row_count",
"positive_rate",
"auc",
"brier_beats_constant",
"top_rows",
"top_positive_rate",
"all_actual_edge_bps",
"top_actual_edge_bps",
"top_edge_lift_bps",
]
].rename(
columns={
"row_count": "tune_rows",
"positive_rate": "tune_positive_rate",
"auc": "tune_auc",
"brier_beats_constant": "tune_brier_beats_constant",
"top_rows": "tune_top_rows",
"top_positive_rate": "tune_top_positive_rate",
"all_actual_edge_bps": "tune_all_actual_edge_bps",
"top_actual_edge_bps": "tune_top_actual_edge_bps",
"top_edge_lift_bps": "tune_top_edge_lift_bps",
}
)
for split_id in (VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT):
split_rows = metrics[metrics["split_id"].eq(split_id)][
key_columns + ["row_count", "positive_rate", "auc", "brier_beats_constant", "top_rows", "top_positive_rate", "all_actual_edge_bps", "top_actual_edge_bps", "top_edge_lift_bps"]
].rename(
columns={
"row_count": f"{split_id}_rows",
"positive_rate": f"{split_id}_positive_rate",
"auc": f"{split_id}_auc",
"brier_beats_constant": f"{split_id}_brier_beats_constant",
"top_rows": f"{split_id}_top_rows",
"top_positive_rate": f"{split_id}_top_positive_rate",
"all_actual_edge_bps": f"{split_id}_all_actual_edge_bps",
"top_actual_edge_bps": f"{split_id}_top_actual_edge_bps",
"top_edge_lift_bps": f"{split_id}_top_edge_lift_bps",
}
)
candidates = candidates.merge(split_rows, on=key_columns, how="left")
top_edge_columns = ["tune_top_actual_edge_bps", f"{VALIDATION_LOCKED_SPLIT}_top_actual_edge_bps", f"{LATEST_STRESS_SPLIT}_top_actual_edge_bps"]
auc_columns = ["tune_auc", f"{VALIDATION_LOCKED_SPLIT}_auc", f"{LATEST_STRESS_SPLIT}_auc"]
lift_columns = ["tune_top_edge_lift_bps", f"{VALIDATION_LOCKED_SPLIT}_top_edge_lift_bps", f"{LATEST_STRESS_SPLIT}_top_edge_lift_bps"]
candidates["min_top_edge_bps"] = candidates[top_edge_columns].min(axis=1)
candidates["mean_top_edge_bps"] = candidates[top_edge_columns].mean(axis=1)
candidates["min_auc"] = candidates[auc_columns].min(axis=1)
candidates["stable_positive"] = candidates[top_edge_columns].gt(0.0).all(axis=1)
candidates["stable_lift"] = candidates[lift_columns].gt(0.0).all(axis=1)
candidates["score"] = candidates["min_top_edge_bps"].fillna(-999.0) + candidates["mean_top_edge_bps"].fillna(-999.0) * 0.25 + candidates["stable_positive"].astype(float) * 2.0
return candidates.sort_values("score", ascending=False).reset_index(drop=True)
def _markdown_report(result: dict[str, Any], candidates: pd.DataFrame) -> str:
lines = [
"# 条件化 Entry 训练诊断报告",
"",
"这份报告只做诊断,不导出上线模型。它先用未来机会做过滤,模拟“Direction 已经筛过一层”的训练人群。",
"",
"**注意:这里的过滤条件用了未来机会,不能直接上线,只能判断条件化 Entry 训练是否值得做。**",
"",
f"- run_id: `{result['run_id']}`",
f"- 特征数: `{result['feature_count']}`",
f"- 条件机会阈值: `{','.join(str(item) for item in result['condition_opportunity_bps'])}`",
f"- 目标真实收益阈值: `{','.join(str(item) for item in result['target_edge_bps'])}`",
f"- 模型类型: `{','.join(result['model_families'])}`",
f"- top 档位: `{','.join(str(item) for item in result['top_fractions'])}`",
f"- 候选数: `{result['candidate_count']}`",
f"- 三段 top 真实收益都转正: `{result['stable_positive_count']}`",
"",
]
if candidates.empty:
lines.extend(["## 候选", "", "没有候选。", ""])
return "\n".join(lines)
display_columns = [
"side",
"model_family",
"condition_opportunity_bps",
"target_edge_bps",
"top_fraction",
"tune_top_actual_edge_bps",
f"{VALIDATION_LOCKED_SPLIT}_top_actual_edge_bps",
f"{LATEST_STRESS_SPLIT}_top_actual_edge_bps",
"min_top_edge_bps",
"stable_positive",
"stable_lift",
"score",
]
lines.extend(
[
"## 候选",
"",
_markdown_table(candidates[display_columns].head(30)),
"",
"## 文件",
"",
"- `diagnostics/conditional_entry_probe_metrics.csv`: 每个组合、每个数据段的完整指标。",
"- `diagnostics/conditional_entry_probe_candidates.csv`: 汇总后的候选排序。",
"",
]
)
return "\n".join(lines)