433 lines
20 KiB
Python
433 lines
20 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pandas as pd
|
||
|
|
from sklearn.linear_model import HuberRegressor, LogisticRegression
|
||
|
|
from sklearn.metrics import brier_score_loss, mean_absolute_error, roc_auc_score
|
||
|
|
from sklearn.preprocessing import StandardScaler
|
||
|
|
|
||
|
|
from trader_training.io_utils import read_json, read_parquet, run_root, sha256_json, write_json, write_parquet, write_text
|
||
|
|
from trader_training.labels import _build_path_stats
|
||
|
|
from trader_training.schemas import FEATURE_ORDER, FIT_SPLIT, LATEST_STRESS_SPLIT, TUNE_SPLIT, VALIDATION_LOCKED_SPLIT
|
||
|
|
|
||
|
|
|
||
|
|
STATE_FEATURES = [
|
||
|
|
"position_side_sign",
|
||
|
|
"time_in_position_minutes",
|
||
|
|
"unrealized_pnl_bps",
|
||
|
|
"mfe_since_entry_bps",
|
||
|
|
"mae_since_entry_bps",
|
||
|
|
"distance_to_stop_bps",
|
||
|
|
"distance_to_target_bps",
|
||
|
|
]
|
||
|
|
|
||
|
|
EVAL_SPLITS = (TUNE_SPLIT, VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT)
|
||
|
|
ALL_SPLITS = (FIT_SPLIT, TUNE_SPLIT, VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT)
|
||
|
|
|
||
|
|
|
||
|
|
def run_state_continue_experiment(args: Any) -> None:
|
||
|
|
root = run_root(args)
|
||
|
|
baseline_root = args.data_root / "trader-v4" / "runs" / args.baseline_run_id
|
||
|
|
out_dir = root / "experiments" / "state_continue"
|
||
|
|
ages = _parse_ages(args.ages_minutes)
|
||
|
|
logging.info(
|
||
|
|
"trader.training.state_continue_experiment_started runId=%s baselineRunId=%s ages=%s",
|
||
|
|
args.run_id,
|
||
|
|
args.baseline_run_id,
|
||
|
|
ages,
|
||
|
|
)
|
||
|
|
|
||
|
|
feature = _load_feature_frame(baseline_root)
|
||
|
|
entry = _load_entry_labels(baseline_root)
|
||
|
|
replay = _load_replay(baseline_root)
|
||
|
|
plan = read_json(baseline_root / "label" / "price_plan_context.json")
|
||
|
|
stop_bps = float(plan["stopDistanceBps"])
|
||
|
|
target_bps = float(plan["targetDistanceBps"])
|
||
|
|
cost_bps = float(plan["costBps"])
|
||
|
|
|
||
|
|
state_frame = _build_state_frame(feature, entry, replay, ages, stop_bps, target_bps, cost_bps)
|
||
|
|
if args.max_rows_per_split:
|
||
|
|
state_frame = _cap_rows_per_split(state_frame, int(args.max_rows_per_split))
|
||
|
|
dataset_hash = write_parquet(out_dir / "state_continue_train.parquet", state_frame)
|
||
|
|
logging.info(
|
||
|
|
"trader.training.state_continue_dataset_written runId=%s rowCount=%s splitCounts=%s path=%s",
|
||
|
|
args.run_id,
|
||
|
|
len(state_frame),
|
||
|
|
state_frame["split_id"].value_counts().to_dict(),
|
||
|
|
out_dir / "state_continue_train.parquet",
|
||
|
|
)
|
||
|
|
|
||
|
|
source_manifest = _source_manifest(args, baseline_root, ages, stop_bps, target_bps, cost_bps, state_frame, dataset_hash)
|
||
|
|
write_json(out_dir / "experiment_manifest.json", source_manifest)
|
||
|
|
write_json(out_dir / "position_state_feature_schema.json", _state_feature_schema())
|
||
|
|
order_hash = write_json(out_dir / "position_state_feature_order.json", STATE_FEATURES)
|
||
|
|
write_json(
|
||
|
|
out_dir / "position_state_source_manifest.json",
|
||
|
|
{
|
||
|
|
"entry_predicted_edge_bps": "NOT_USED_IN_THIS_DIAGNOSTIC",
|
||
|
|
"entry_direction_prob": "NOT_USED_IN_THIS_DIAGNOSTIC",
|
||
|
|
"out_of_fold_used": False,
|
||
|
|
"frozen_model_output_used": False,
|
||
|
|
"replay_decision_trace_used": False,
|
||
|
|
"state_feature_order_hash": order_hash,
|
||
|
|
"row_count": len(state_frame),
|
||
|
|
"split_counts": state_frame["split_id"].value_counts().to_dict(),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
feature_sets = {
|
||
|
|
"market_only": FEATURE_ORDER,
|
||
|
|
"market_plus_state": [*FEATURE_ORDER, *STATE_FEATURES],
|
||
|
|
}
|
||
|
|
results: dict[str, Any] = {}
|
||
|
|
prediction_frames: list[pd.DataFrame] = []
|
||
|
|
for side in ("LONG", "SHORT"):
|
||
|
|
side_frame = state_frame[state_frame["position_side"].eq(side)].copy()
|
||
|
|
for feature_set_name, feature_columns in feature_sets.items():
|
||
|
|
key = f"{side.lower()}_{feature_set_name}"
|
||
|
|
result, predictions = _train_side_models(side_frame, side, feature_columns)
|
||
|
|
results[key] = result
|
||
|
|
predictions["side"] = side
|
||
|
|
predictions["feature_set"] = feature_set_name
|
||
|
|
prediction_frames.append(predictions)
|
||
|
|
logging.info(
|
||
|
|
"trader.training.state_continue_model_trained runId=%s side=%s featureSet=%s tuneAuc=%s tuneMaeRatio=%s",
|
||
|
|
args.run_id,
|
||
|
|
side,
|
||
|
|
feature_set_name,
|
||
|
|
result.get(TUNE_SPLIT, {}).get("continue_auc"),
|
||
|
|
result.get(TUNE_SPLIT, {}).get("edge_mae_vs_constant_ratio"),
|
||
|
|
)
|
||
|
|
|
||
|
|
predictions = pd.concat(prediction_frames, ignore_index=True) if prediction_frames else pd.DataFrame()
|
||
|
|
write_parquet(out_dir / "state_continue_predictions.parquet", predictions)
|
||
|
|
write_json(out_dir / "state_continue_result.json", results)
|
||
|
|
write_text(out_dir / "state_continue_experiment_report.md", _report(args, baseline_root, source_manifest, results))
|
||
|
|
logging.info("trader.training.state_continue_experiment_finished runId=%s report=%s", args.run_id, out_dir / "state_continue_experiment_report.md")
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_ages(raw: str) -> list[int]:
|
||
|
|
ages = [int(item.strip()) for item in raw.split(",") if item.strip()]
|
||
|
|
if not ages or any(age <= 0 for age in ages):
|
||
|
|
raise ValueError(f"invalid ages-minutes: {raw}")
|
||
|
|
return sorted(set(ages))
|
||
|
|
|
||
|
|
|
||
|
|
def _load_feature_frame(baseline_root: Path) -> pd.DataFrame:
|
||
|
|
feature = read_parquet(baseline_root / "feature" / "feature_frame.parquet")
|
||
|
|
required = {"sample_id", "symbol", "event_time", "open_time_ms", "split_id", "walk_forward_fold", "data_quality_flag", *FEATURE_ORDER}
|
||
|
|
missing = sorted(required.difference(feature.columns))
|
||
|
|
if missing:
|
||
|
|
raise ValueError(f"baseline feature frame missing columns: {missing}")
|
||
|
|
feature = feature[feature["data_quality_flag"].isin(["OK", "PARTIAL_OPTIONAL"])].copy()
|
||
|
|
feature = feature[feature["split_id"].isin(ALL_SPLITS)].copy()
|
||
|
|
return feature
|
||
|
|
|
||
|
|
|
||
|
|
def _load_entry_labels(baseline_root: Path) -> pd.DataFrame:
|
||
|
|
entry = read_parquet(baseline_root / "label" / "entry_labels.parquet")
|
||
|
|
required = {"sample_id", "symbol", "event_time", "side", "entry_target", "split_id", "walk_forward_fold"}
|
||
|
|
missing = sorted(required.difference(entry.columns))
|
||
|
|
if missing:
|
||
|
|
raise ValueError(f"baseline entry labels missing columns: {missing}")
|
||
|
|
entry = entry[(entry["entry_target"] == 1) & (entry["side"].isin(["LONG", "SHORT"]))].copy()
|
||
|
|
entry["entry_open_time_ms"] = pd.to_datetime(entry["event_time"], utc=True).astype("int64") // 1_000_000
|
||
|
|
return entry[["sample_id", "symbol", "event_time", "side", "entry_open_time_ms"]].copy()
|
||
|
|
|
||
|
|
|
||
|
|
def _load_replay(baseline_root: Path) -> pd.DataFrame:
|
||
|
|
split_manifest = read_json(baseline_root / "split" / "split_manifest.json")
|
||
|
|
replay_path = Path(split_manifest["source_replay_path"])
|
||
|
|
replay = read_parquet(replay_path)
|
||
|
|
required = {"symbol", "event_time", "open_time_ms", "high", "low", "close", "spread_bps"}
|
||
|
|
missing = sorted(required.difference(replay.columns))
|
||
|
|
if missing:
|
||
|
|
raise ValueError(f"source replay missing columns: {missing}")
|
||
|
|
return replay.sort_values(["symbol", "open_time_ms"]).reset_index(drop=True)
|
||
|
|
|
||
|
|
|
||
|
|
def _build_state_frame(
|
||
|
|
feature: pd.DataFrame,
|
||
|
|
entry: pd.DataFrame,
|
||
|
|
replay: pd.DataFrame,
|
||
|
|
ages: list[int],
|
||
|
|
stop_bps: float,
|
||
|
|
target_bps: float,
|
||
|
|
cost_bps: float,
|
||
|
|
) -> pd.DataFrame:
|
||
|
|
future_stats = _build_path_stats(replay, horizon=30, target_bps=target_bps, stop_bps=stop_bps)
|
||
|
|
future_stats = future_stats.rename(columns={"open_time_ms": "current_open_time_ms"})
|
||
|
|
current_feature = feature.rename(columns={"sample_id": "current_sample_id", "event_time": "current_event_time", "open_time_ms": "current_open_time_ms"})
|
||
|
|
replay_state_source = _state_source_by_age(replay, ages)
|
||
|
|
frames: list[pd.DataFrame] = []
|
||
|
|
for age in ages:
|
||
|
|
candidates = entry.copy()
|
||
|
|
candidates["time_in_position_minutes"] = age
|
||
|
|
candidates["current_open_time_ms"] = candidates["entry_open_time_ms"] + age * 60_000
|
||
|
|
candidates = candidates.merge(
|
||
|
|
replay_state_source[replay_state_source["time_in_position_minutes"].eq(age)],
|
||
|
|
on=["symbol", "current_open_time_ms", "time_in_position_minutes"],
|
||
|
|
how="inner",
|
||
|
|
)
|
||
|
|
candidates = candidates.merge(current_feature, on=["symbol", "current_open_time_ms"], how="inner")
|
||
|
|
candidates = candidates.merge(
|
||
|
|
future_stats,
|
||
|
|
left_on=["symbol", "current_open_time_ms", "side"],
|
||
|
|
right_on=["symbol", "current_open_time_ms", "side"],
|
||
|
|
how="inner",
|
||
|
|
)
|
||
|
|
if candidates.empty:
|
||
|
|
continue
|
||
|
|
frames.append(_state_rows_for_age(candidates, stop_bps, target_bps, cost_bps))
|
||
|
|
logging.info("trader.training.state_continue_age_built ageMinutes=%s rowCount=%s", age, len(candidates))
|
||
|
|
if not frames:
|
||
|
|
raise ValueError("state continue experiment produced no rows")
|
||
|
|
out = pd.concat(frames, ignore_index=True)
|
||
|
|
out = out.replace([np.inf, -np.inf], np.nan)
|
||
|
|
required = [*FEATURE_ORDER, *STATE_FEATURES, "continue_target", "expected_continue_edge_bps"]
|
||
|
|
out = out.dropna(subset=required).copy()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _state_source_by_age(replay: pd.DataFrame, ages: list[int]) -> pd.DataFrame:
|
||
|
|
frames: list[pd.DataFrame] = []
|
||
|
|
for _, group in replay.groupby("symbol", sort=False, observed=False):
|
||
|
|
group = group.sort_values("open_time_ms").copy()
|
||
|
|
for age in ages:
|
||
|
|
rolling_high = group["high"].rolling(age + 1, min_periods=age + 1).max()
|
||
|
|
rolling_low = group["low"].rolling(age + 1, min_periods=age + 1).min()
|
||
|
|
frame = pd.DataFrame(
|
||
|
|
{
|
||
|
|
"symbol": group["symbol"],
|
||
|
|
"current_open_time_ms": group["open_time_ms"],
|
||
|
|
"time_in_position_minutes": age,
|
||
|
|
"entry_price": group["close"].shift(age),
|
||
|
|
"current_price": group["close"],
|
||
|
|
"high_since_entry": rolling_high,
|
||
|
|
"low_since_entry": rolling_low,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
frames.append(frame.dropna())
|
||
|
|
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||
|
|
|
||
|
|
|
||
|
|
def _state_rows_for_age(frame: pd.DataFrame, stop_bps: float, target_bps: float, cost_bps: float) -> pd.DataFrame:
|
||
|
|
side_sign = np.where(frame["side"].eq("LONG"), 1.0, -1.0)
|
||
|
|
entry_price = frame["entry_price"].astype(float)
|
||
|
|
current_price = frame["current_price"].astype(float)
|
||
|
|
high_since = frame["high_since_entry"].astype(float)
|
||
|
|
low_since = frame["low_since_entry"].astype(float)
|
||
|
|
|
||
|
|
long_mask = frame["side"].eq("LONG")
|
||
|
|
unrealized = np.where(long_mask, (current_price / entry_price - 1.0) * 10000.0, (entry_price / current_price - 1.0) * 10000.0) - cost_bps
|
||
|
|
mfe = np.where(long_mask, (high_since / entry_price - 1.0) * 10000.0, (entry_price / low_since - 1.0) * 10000.0)
|
||
|
|
mae = np.where(long_mask, (entry_price / low_since - 1.0) * 10000.0, (high_since / entry_price - 1.0) * 10000.0)
|
||
|
|
stop_price = np.where(long_mask, entry_price * (1.0 - stop_bps / 10000.0), entry_price * (1.0 + stop_bps / 10000.0))
|
||
|
|
target_price = np.where(long_mask, entry_price * (1.0 + target_bps / 10000.0), entry_price * (1.0 - target_bps / 10000.0))
|
||
|
|
distance_to_stop = np.where(long_mask, (current_price / stop_price - 1.0) * 10000.0, (stop_price / current_price - 1.0) * 10000.0)
|
||
|
|
distance_to_target = np.where(long_mask, (target_price / current_price - 1.0) * 10000.0, (current_price / target_price - 1.0) * 10000.0)
|
||
|
|
expected_edge = frame["future_return_bps"].astype(float) - cost_bps
|
||
|
|
continue_target = ((expected_edge >= 2.0) & (frame["mae_bps"].astype(float) < stop_bps)).astype("int8")
|
||
|
|
|
||
|
|
out = frame[
|
||
|
|
[
|
||
|
|
"current_sample_id",
|
||
|
|
"symbol",
|
||
|
|
"current_event_time",
|
||
|
|
"current_open_time_ms",
|
||
|
|
"side",
|
||
|
|
"split_id",
|
||
|
|
"walk_forward_fold",
|
||
|
|
*FEATURE_ORDER,
|
||
|
|
]
|
||
|
|
].copy()
|
||
|
|
out = out.rename(
|
||
|
|
columns={
|
||
|
|
"current_sample_id": "sample_id",
|
||
|
|
"current_event_time": "event_time",
|
||
|
|
"current_open_time_ms": "open_time_ms",
|
||
|
|
"side": "position_side",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
out["position_side_sign"] = side_sign.astype("float32")
|
||
|
|
out["time_in_position_minutes"] = frame["time_in_position_minutes"].astype("float32")
|
||
|
|
out["unrealized_pnl_bps"] = unrealized.astype("float32")
|
||
|
|
out["mfe_since_entry_bps"] = np.maximum(mfe, 0.0).astype("float32")
|
||
|
|
out["mae_since_entry_bps"] = np.maximum(mae, 0.0).astype("float32")
|
||
|
|
out["distance_to_stop_bps"] = distance_to_stop.astype("float32")
|
||
|
|
out["distance_to_target_bps"] = distance_to_target.astype("float32")
|
||
|
|
out["continue_target"] = continue_target
|
||
|
|
out["expected_continue_edge_bps"] = expected_edge.astype("float32")
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _cap_rows_per_split(frame: pd.DataFrame, max_rows_per_split: int) -> pd.DataFrame:
|
||
|
|
capped = []
|
||
|
|
for split_id, part in frame.sort_values("event_time").groupby("split_id", sort=False, observed=False):
|
||
|
|
if len(part) > max_rows_per_split:
|
||
|
|
part = part.tail(max_rows_per_split).copy()
|
||
|
|
capped.append(part)
|
||
|
|
logging.info("trader.training.state_continue_split_capped splitId=%s rowCount=%s maxRows=%s", split_id, len(part), max_rows_per_split)
|
||
|
|
return pd.concat(capped, ignore_index=True)
|
||
|
|
|
||
|
|
|
||
|
|
def _train_side_models(frame: pd.DataFrame, side: str, feature_columns: list[str]) -> tuple[dict[str, Any], pd.DataFrame]:
|
||
|
|
train = frame[frame["split_id"].eq(FIT_SPLIT)].copy()
|
||
|
|
if train.empty:
|
||
|
|
raise ValueError(f"state continue {side} has no fit_inner rows")
|
||
|
|
scaler = StandardScaler()
|
||
|
|
x_train = scaler.fit_transform(train[feature_columns].astype("float32"))
|
||
|
|
y_train_cls = train["continue_target"].astype(int).to_numpy()
|
||
|
|
y_train_reg = train["expected_continue_edge_bps"].astype(float).to_numpy()
|
||
|
|
|
||
|
|
clf = LogisticRegression(max_iter=500)
|
||
|
|
clf.fit(x_train, y_train_cls)
|
||
|
|
reg = HuberRegressor(alpha=0.001, epsilon=1.35, max_iter=300)
|
||
|
|
reg.fit(x_train, y_train_reg)
|
||
|
|
|
||
|
|
metrics: dict[str, Any] = {}
|
||
|
|
prediction_frames: list[pd.DataFrame] = []
|
||
|
|
for split_id in ALL_SPLITS:
|
||
|
|
part = frame[frame["split_id"].eq(split_id)].copy()
|
||
|
|
if part.empty:
|
||
|
|
continue
|
||
|
|
x = scaler.transform(part[feature_columns].astype("float32"))
|
||
|
|
y_cls = part["continue_target"].astype(int).to_numpy()
|
||
|
|
y_reg = part["expected_continue_edge_bps"].astype(float).to_numpy()
|
||
|
|
proba = clf.predict_proba(x)[:, 1]
|
||
|
|
pred_edge = reg.predict(x)
|
||
|
|
metrics[split_id] = _split_metrics(y_train_cls, y_train_reg, y_cls, y_reg, proba, pred_edge)
|
||
|
|
pred_frame = part[["sample_id", "symbol", "event_time", "split_id", "position_side", "continue_target", "expected_continue_edge_bps"]].copy()
|
||
|
|
pred_frame["continue_prob"] = proba.astype("float32")
|
||
|
|
pred_frame["predicted_continue_edge_bps"] = pred_edge.astype("float32")
|
||
|
|
prediction_frames.append(pred_frame)
|
||
|
|
metrics["row_count"] = int(len(frame))
|
||
|
|
metrics["feature_count"] = len(feature_columns)
|
||
|
|
metrics["feature_hash"] = sha256_json(feature_columns)
|
||
|
|
return metrics, pd.concat(prediction_frames, ignore_index=True)
|
||
|
|
|
||
|
|
|
||
|
|
def _split_metrics(
|
||
|
|
y_train_cls: np.ndarray,
|
||
|
|
y_train_reg: np.ndarray,
|
||
|
|
y_cls: np.ndarray,
|
||
|
|
y_reg: np.ndarray,
|
||
|
|
proba: np.ndarray,
|
||
|
|
pred_edge: np.ndarray,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
train_rate = float(np.mean(y_train_cls))
|
||
|
|
constant_proba = np.full(len(y_cls), train_rate)
|
||
|
|
train_median = float(np.median(y_train_reg))
|
||
|
|
constant_edge = np.full(len(y_reg), train_median)
|
||
|
|
out: dict[str, Any] = {
|
||
|
|
"row_count": int(len(y_cls)),
|
||
|
|
"positive_rate": float(np.mean(y_cls)),
|
||
|
|
"brier": float(brier_score_loss(y_cls, proba)),
|
||
|
|
"constant_brier": float(brier_score_loss(y_cls, constant_proba)),
|
||
|
|
"edge_mae": float(mean_absolute_error(y_reg, pred_edge)),
|
||
|
|
"edge_constant_mae": float(mean_absolute_error(y_reg, constant_edge)),
|
||
|
|
}
|
||
|
|
if len(np.unique(y_cls)) == 2:
|
||
|
|
out["continue_auc"] = float(roc_auc_score(y_cls, proba))
|
||
|
|
out["brier_vs_constant_ratio"] = float(out["brier"] / out["constant_brier"]) if out["constant_brier"] > 0 else None
|
||
|
|
out["edge_mae_vs_constant_ratio"] = float(out["edge_mae"] / out["edge_constant_mae"]) if out["edge_constant_mae"] > 0 else None
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _source_manifest(
|
||
|
|
args: Any,
|
||
|
|
baseline_root: Path,
|
||
|
|
ages: list[int],
|
||
|
|
stop_bps: float,
|
||
|
|
target_bps: float,
|
||
|
|
cost_bps: float,
|
||
|
|
state_frame: pd.DataFrame,
|
||
|
|
dataset_hash: str,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"experiment": "state_continue_diagnostic_v1",
|
||
|
|
"run_id": args.run_id,
|
||
|
|
"baseline_run_id": args.baseline_run_id,
|
||
|
|
"baseline_root": str(baseline_root),
|
||
|
|
"ages_minutes": ages,
|
||
|
|
"target_bps": target_bps,
|
||
|
|
"stop_bps": stop_bps,
|
||
|
|
"cost_bps": cost_bps,
|
||
|
|
"dataset_hash_sha256": dataset_hash,
|
||
|
|
"row_count": int(len(state_frame)),
|
||
|
|
"split_counts": state_frame["split_id"].value_counts().to_dict(),
|
||
|
|
"side_counts": state_frame["position_side"].value_counts().to_dict(),
|
||
|
|
"feature_inputs": {
|
||
|
|
"market_feature_count": len(FEATURE_ORDER),
|
||
|
|
"state_features": STATE_FEATURES,
|
||
|
|
"state_feature_count": len(STATE_FEATURES),
|
||
|
|
},
|
||
|
|
"leakage_policy": {
|
||
|
|
"uses_future_entry_label_as_feature": False,
|
||
|
|
"uses_same_round_model_prediction_as_feature": False,
|
||
|
|
"entry_predicted_edge_bps": "not used",
|
||
|
|
"entry_direction_prob": "not used",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _state_feature_schema() -> list[dict[str, Any]]:
|
||
|
|
return [
|
||
|
|
{"name": "position_side_sign", "unit": "-1/1", "source": "synthetic position state", "leakage_check": "known at current position time"},
|
||
|
|
{"name": "time_in_position_minutes", "unit": "minute", "source": "entry time to current time", "leakage_check": "known at current position time"},
|
||
|
|
{"name": "unrealized_pnl_bps", "unit": "bps", "source": "entry price and current close", "leakage_check": "uses <= current time price"},
|
||
|
|
{"name": "mfe_since_entry_bps", "unit": "bps", "source": "high since entry", "leakage_check": "uses only entry..current high"},
|
||
|
|
{"name": "mae_since_entry_bps", "unit": "bps", "source": "low/high since entry", "leakage_check": "uses only entry..current low/high"},
|
||
|
|
{"name": "distance_to_stop_bps", "unit": "bps", "source": "price plan and current close", "leakage_check": "uses fixed plan and current price"},
|
||
|
|
{"name": "distance_to_target_bps", "unit": "bps", "source": "price plan and current close", "leakage_check": "uses fixed plan and current price"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _report(args: Any, baseline_root: Path, manifest: dict[str, Any], results: dict[str, Any]) -> str:
|
||
|
|
baseline = read_json(baseline_root / "model" / "model_train_manifest.json")
|
||
|
|
continue_metrics = baseline["CONTINUE"]["metrics"]
|
||
|
|
lines = [
|
||
|
|
"# State Continue Experiment Report",
|
||
|
|
"",
|
||
|
|
f"- run_id: `{args.run_id}`",
|
||
|
|
f"- baseline_run_id: `{args.baseline_run_id}`",
|
||
|
|
f"- row_count: `{manifest['row_count']}`",
|
||
|
|
f"- ages_minutes: `{manifest['ages_minutes']}`",
|
||
|
|
"",
|
||
|
|
"## Baseline run-10 Continue",
|
||
|
|
"",
|
||
|
|
"| head | auc | mae_vs_constant |",
|
||
|
|
"| --- | ---: | ---: |",
|
||
|
|
f"| long_continue_prob | {continue_metrics['long_continue_prob'].get('auc')} | |",
|
||
|
|
f"| short_continue_prob | {continue_metrics['short_continue_prob'].get('auc')} | |",
|
||
|
|
f"| long_expected_continue_edge_bps | | {continue_metrics['long_expected_continue_edge_bps'].get('mae_vs_constant_ratio')} |",
|
||
|
|
f"| short_expected_continue_edge_bps | | {continue_metrics['short_expected_continue_edge_bps'].get('mae_vs_constant_ratio')} |",
|
||
|
|
"",
|
||
|
|
"## Diagnostic Result",
|
||
|
|
"",
|
||
|
|
"| side | feature_set | split | rows | auc | brier_ratio | mae_ratio |",
|
||
|
|
"| --- | --- | --- | ---: | ---: | ---: | ---: |",
|
||
|
|
]
|
||
|
|
for key, item in results.items():
|
||
|
|
side, feature_set = key.split("_", 1)
|
||
|
|
for split_id in EVAL_SPLITS:
|
||
|
|
metric = item.get(split_id, {})
|
||
|
|
lines.append(
|
||
|
|
f"| {side.upper()} | {feature_set} | {split_id} | {metric.get('row_count')} | {metric.get('continue_auc')} | {metric.get('brier_vs_constant_ratio')} | {metric.get('edge_mae_vs_constant_ratio')} |"
|
||
|
|
)
|
||
|
|
lines.extend(
|
||
|
|
[
|
||
|
|
"",
|
||
|
|
"## Verdict Rule",
|
||
|
|
"",
|
||
|
|
"状态特征只有在 `market_plus_state` 同时好过 `market_only`,并且 validation_locked / latest_stress 没有反向变差时,才进入正式链路。",
|
||
|
|
"",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
return "\n".join(lines)
|