Files
mymt5opp/scripts/build_feature_datasets.py
gavindiaz 63a829cc46 phase 7-8 完成 + warmup 修复 + 产物结构化重组
主要内容:
- Phase 8 PROMOTE: finalist #1 (trial #324) registry 条目,自动生成
- Optuna objective warmup bug 修复 (shared/optimizer/objective.py)
- studies/ 目录按用途重组为 optuna/ + finalists/ + features/ 三层
- reports/ 加入 Optuna 中文 dashboard (5 主图 + 18 slice + 15 contour)
- 新增 PROJECT_GUIDE.md 项目说明文档
- 新增 build_registry_entry.py / build_optuna_dashboard.py / build_feature_datasets.py
- .gitignore: 允许提交 studies/*.db (Optuna DB) 和 reports/*.html (MT5 + dashboard)
2026-06-27 00:28:07 +08:00

351 lines
16 KiB
Python

"""Build machine-learning-ready feature parquet datasets.
Two datasets are produced for the current finalist #1 (trial #324, the one
in ``registry/``):
1. ``studies/features/trade_features_gold_scalper_pro_is2025.parquet`` (+ .csv) — one row per closed trade,
with the indicator + market state at entry time. Used for "which entry
conditions predict winning trades" classification / feature analysis.
2. ``studies/features/trial_features_gold_scalper_pro_is2025.parquet`` (+ .csv) — one row per Optuna trial,
with all params + the objective's reported metrics. Used for parameter-
sensitivity analysis, parameter importance, and meta-learning.
Both are written as Parquet (binary, typed) and CSV (human-readable) so you
can ``pd.read_parquet`` for ML or open the CSV in Excel.
Usage:
python scripts/build_feature_datasets.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT))
import json
import optuna
import numpy as np
import pandas as pd
from shared.core.engine import SizingInputs
from shared.data.loaders import load_bars
from shared.indicators.base import atr, ema, rsi
from strategies.gold_scalper_pro.instruments import XAUUSD_REAL
from strategies.gold_scalper_pro.scalper_engine import (
ScalperEngine,
engine_kwargs_from_params,
)
from strategies.gold_scalper_pro.search_space import FROZEN_BASELINE
from strategies.gold_scalper_pro.signals import build_signals
# ─────────────────────────────────────────────────────────────────────────────
# Trade-level features
# ─────────────────────────────────────────────────────────────────────────────
TRADE_FEATURE_COLUMNS = [
# identity
"trade_id",
# timing
"entry_time", "exit_time", "duration_minutes",
"hour_of_day", "day_of_week",
# trade
"direction", "entry_price", "exit_price", "lots",
"pnl", "swap", "exit_reason", "is_win", "pnl_pct",
# sizing context
"equity_at_entry", "risk_percent", "sl_distance", "sl_distance_pct",
# indicators at entry (computed on the SIGNAL bar, i.e. one bar before fill)
"atr_at_entry", "atr_pct_of_close",
"rsi_at_entry",
"fast_ema_at_entry", "slow_ema_at_entry",
"dist_to_fast", "dist_to_fast_atr",
"dist_to_slow",
"fast_minus_slow",
"trend_up",
"close_at_entry", "high_at_entry", "low_at_entry",
"spread_at_entry", "spread_atr_ratio",
# ML target candidates (the user can pick)
"label_win", # binary 0/1 — classification target
"label_pnl_zscore", # z-score of pnl across all trades — regression target
]
def build_trade_features(
bars: pd.DataFrame,
m1_bars: pd.DataFrame,
params: dict,
is_start: pd.Timestamp,
is_end: pd.Timestamp,
) -> pd.DataFrame:
"""Run finalist #1 with warmup, then build a per-trade feature table."""
# Warmup pattern: signals on full bars, slice to IS window for engine.
pack = build_signals(params, bars, XAUUSD_REAL)
ts = pd.to_datetime(bars["timestamp"].to_numpy())
lo = int(ts.searchsorted(is_start, side="left"))
hi = int(ts.searchsorted(is_end, side="left"))
bars_is = bars.iloc[lo:hi].reset_index(drop=True)
sig_long = pack.signals_long[lo:hi]
sig_short = pack.signals_short[lo:hi]
sl_p = pack.sl_prices[lo:hi]
tp_p = pack.tp_prices[lo:hi]
m1_ts = pd.to_datetime(m1_bars["timestamp"].to_numpy())
m1_lo = int(m1_ts.searchsorted(is_start, side="left"))
m1_hi = int(m1_ts.searchsorted(is_end, side="left"))
m1_is = m1_bars.iloc[m1_lo:m1_hi].reset_index(drop=True)
engine = ScalperEngine()
result = engine.run(
bars_is, sig_long, sig_short, sl_p, tp_p,
XAUUSD_REAL, SizingInputs(), 1000.0,
m1_bars=m1_is,
**engine_kwargs_from_params(params),
)
trades = result.trades
if not trades:
return pd.DataFrame(columns=TRADE_FEATURE_COLUMNS)
# Recompute indicator arrays on the full bars (same as build_signals),
# then index by each trade's entry_time to get the at-entry state.
close = bars["close"].to_numpy(dtype=float)
high = bars["high"].to_numpy(dtype=float)
low = bars["low"].to_numpy(dtype=float)
atr_arr = atr(high, low, close, int(params["InpAtrPeriod"]))
rsi_arr = rsi(close, int(params["InpRsiPeriod"]))
fast_e = ema(close, int(params["InpFastEmaPeriod"]))
slow_e = ema(close, int(params["InpSlowEmaPeriod"]))
spread_pts = bars["spread"].to_numpy(dtype=float) if "spread" in bars else np.zeros(len(bars))
spread_px = spread_pts * XAUUSD_REAL.point
bars_ts = pd.to_datetime(bars["timestamp"].to_numpy())
# Pre-build a ts → idx lookup so per-trade search is O(log n).
# Each trade's entry_time is the bar AFTER the signal bar (the engine fills
# at next-bar open), so we look up the bar index for entry_time, then take
# idx-1 as the signal bar (where indicators are read).
bar_idx_at = pd.Index(bars_ts)
def signal_idx(entry_time: pd.Timestamp) -> int:
# The engine records entry_time as the fill bar's timestamp. We want
# the PREVIOUS bar (the signal bar where indicators were ready).
pos = bar_idx_at.get_indexer([entry_time], method="pad")[0]
return int(pos) - 1 if pos > 0 else 0
rows = []
risk_pct = float(params["InpRiskPercent"])
atr_sl_mult = float(params["InpAtrSLMult"])
for i, tr in enumerate(trades):
sig_i = signal_idx(tr.entry_time)
if sig_i < 0 or sig_i >= len(close):
continue
c_sig = close[sig_i]
atr_sig = atr_arr[sig_i]
rsi_sig = rsi_arr[sig_i]
fast_sig = fast_e[sig_i]
slow_sig = slow_e[sig_i]
sp_sig = spread_px[sig_i]
sl_dist = atr_sl_mult * atr_sig
duration_min = (tr.exit_time - tr.entry_time).total_seconds() / 60.0
pnl_pct = (tr.pnl / max(tr.entry_price * tr.lots * XAUUSD_REAL.contract_size, 1e-9)) * 100.0
rows.append({
"trade_id": i + 1,
"entry_time": tr.entry_time,
"exit_time": tr.exit_time,
"duration_minutes": duration_min,
"hour_of_day": int(tr.entry_time.hour),
"day_of_week": int(tr.entry_time.dayofweek),
"direction": tr.direction.name,
"entry_price": tr.entry_price,
"exit_price": tr.exit_price,
"lots": tr.lots,
"pnl": tr.pnl,
"swap": tr.swap,
"exit_reason": tr.exit_reason,
"is_win": bool(tr.pnl > 0),
"pnl_pct": pnl_pct,
"equity_at_entry": float("nan"), # filled below from equity curve
"risk_percent": risk_pct,
"sl_distance": sl_dist,
"sl_distance_pct": (sl_dist / c_sig) * 100.0,
"atr_at_entry": atr_sig,
"atr_pct_of_close": (atr_sig / c_sig) * 100.0,
"rsi_at_entry": rsi_sig,
"fast_ema_at_entry": fast_sig,
"slow_ema_at_entry": slow_sig,
"dist_to_fast": abs(c_sig - fast_sig),
"dist_to_fast_atr": abs(c_sig - fast_sig) / atr_sig if atr_sig > 0 else float("nan"),
"dist_to_slow": abs(c_sig - slow_sig),
"fast_minus_slow": fast_sig - slow_sig,
"trend_up": bool(fast_sig > slow_sig and c_sig > slow_sig),
"close_at_entry": c_sig,
"high_at_entry": high[sig_i],
"low_at_entry": low[sig_i],
"spread_at_entry": sp_sig,
"spread_atr_ratio": sp_sig / atr_sig if atr_sig > 0 else float("nan"),
"label_win": 1 if tr.pnl > 0 else 0,
"label_pnl_zscore": float("nan"), # filled below
})
df = pd.DataFrame(rows)
if df.empty:
return df
# Approximate equity-at-entry from the equity curve (the engine samples
# periodically; the closest sample before entry_time is a fair proxy).
ec = result.equity_curve
if not ec.empty and "equity" in ec.columns:
ec_ts = pd.to_datetime(ec["timestamp"].to_numpy())
ec_eq = ec["equity"].to_numpy(dtype=float)
ec_idx = pd.Index(ec_ts)
positions = ec_idx.get_indexer(df["entry_time"].to_numpy(), method="pad")
positions = np.where(positions < 0, 0, positions)
df["equity_at_entry"] = ec_eq[positions]
# Z-score of pnl across all trades — a regression-style label that
# normalizes for the strategy's overall edge.
if df["pnl"].std() > 0:
df["label_pnl_zscore"] = (df["pnl"] - df["pnl"].mean()) / df["pnl"].std()
return df[TRADE_FEATURE_COLUMNS]
# ─────────────────────────────────────────────────────────────────────────────
# Trial-level features
# ─────────────────────────────────────────────────────────────────────────────
TRIAL_FEATURE_COLUMNS = [
"trial_number", "state",
# searched params (SEARCH_SPACE keys)
"InpFastEmaPeriod", "InpSlowEmaPeriod", "InpRsiPeriod",
"InpRsiBuyLevel", "InpRsiSellLevel", "InpPullbackAtrMult",
"InpAtrPeriod", "InpMaxSpreadAtrPct",
"InpRiskPercent", "InpAtrSLMult", "InpAtrTPMult",
"InpBreakEvenPoints", "InpBreakEvenLock",
"InpTrailStartPoints", "InpTrailStepPoints",
"InpMaxTradesPerDay", "InpDailyLossLimit", "InpMinSecondsBetween",
# objective output
"score",
# user_attrs metrics (written by objective on completion)
"net_profit", "profit_factor", "total_trades",
"max_equity_dd", "max_equity_dd_pct", "win_rate", "sharpe",
# finalist tagging
"is_finalist", "finalist_rank",
# error info
"error_message",
]
def build_trial_features(study: optuna.Study, finalists_json: dict | None) -> pd.DataFrame:
"""One row per Optuna trial with params + metrics + finalist tag."""
# finalist map: trial_number → rank (0/1/2)
finalist_map: dict[int, int] = {}
if finalists_json:
for rank, fl in enumerate(finalists_json.get("finalists", [])):
tn = fl.get("trial_number")
if tn is not None:
finalist_map[int(tn)] = rank
rows = []
for t in study.trials:
# Skip RUNNING / WAITING trials — no metrics yet.
if t.state == optuna.trial.TrialState.COMPLETE:
state = "COMPLETE"
elif t.state == optuna.trial.TrialState.PRUNED:
state = "PRUNED"
elif t.state == optuna.trial.TrialState.FAIL:
state = "FAIL"
else:
continue # RUNNING / WAITING: skip
ua = t.user_attrs or {}
row = {
"trial_number": t.number,
"state": state,
}
# Fill params (None for missing → preserves column type).
for p in TRIAL_FEATURE_COLUMNS:
if p in ("trial_number", "state", "is_finalist", "finalist_rank",
"error_message", "score"):
continue
if p in ("net_profit", "profit_factor", "total_trades",
"max_equity_dd", "max_equity_dd_pct", "win_rate", "sharpe"):
row[p] = ua.get(p)
continue
# param
row[p] = t.params.get(p)
row["score"] = t.value
row["is_finalist"] = t.number in finalist_map
row["finalist_rank"] = finalist_map.get(t.number)
row["error_message"] = (ua.get("error") if state == "FAIL" else None)
rows.append(row)
return pd.DataFrame(rows, columns=TRIAL_FEATURE_COLUMNS)
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
IS_START = pd.Timestamp("2025-01-01 00:00:00")
IS_END = pd.Timestamp("2026-01-01 00:00:00")
# ── Trial features ──────────────────────────────────────────────────────
db = PROJECT / "studies" / "optuna" / "gold_scalper_pro_is2025.db"
finalists_json_path = PROJECT / "studies" / "finalists" / "gold_scalper_pro_is2025-2026.json"
print(f"=== trial-level features ===")
print(f" study: gold_scalper_pro_is2025 ({db.relative_to(PROJECT)})")
study = optuna.load_study(
study_name="gold_scalper_pro_is2025",
storage=f"sqlite:///{db}",
)
finalists_json = None
if finalists_json_path.exists():
import json
finalists_json = json.loads(finalists_json_path.read_text(encoding="utf-8"))
print(f" finalists JSON: {len(finalists_json.get('finalists', []))} entries")
trial_df = build_trial_features(study, finalists_json)
trial_out_parquet = PROJECT / "studies" / "features" / "trial_features_gold_scalper_pro_is2025.parquet"
trial_out_csv = PROJECT / "studies" / "features" / "trial_features_gold_scalper_pro_is2025.csv"
trial_df.to_parquet(trial_out_parquet, index=False)
trial_df.to_csv(trial_out_csv, index=False)
print(f" → {trial_out_parquet.relative_to(PROJECT)} ({len(trial_df):,} rows)")
print(f" → {trial_out_csv.relative_to(PROJECT)}")
complete = trial_df[trial_df["state"] == "COMPLETE"]
print(f" complete: {len(complete):,} finalists: {trial_df['is_finalist'].sum()}")
# ── Trade features (finalist #1 only — the registered one) ──────────────
print(f"\n=== trade-level features (finalist #1) ===")
if finalists_json is None or not finalists_json.get("finalists"):
print(" ERROR: finalists JSON missing — run reeval_finalist_forward.py first")
return 1
f1 = finalists_json["finalists"][0]
f1_trial = study.trials[f1["trial_number"]]
params = {**FROZEN_BASELINE, **f1["params"]}
print(f" finalist #1: trial #{f1_trial.number} score={f1['score']:.4f}")
bars = load_bars(PROJECT / "data" / "XAUUSD_M5_2024-06-26_2026-06-26.parquet")
m1 = load_bars(PROJECT / "data" / "XAUUSD_M1_2024-06-26_2026-06-26.parquet")
print(f" bars : M5={len(bars):,} M1={len(m1):,}")
trade_df = build_trade_features(bars, m1, params, IS_START, IS_END)
trade_out_parquet = PROJECT / "studies" / "features" / "trade_features_gold_scalper_pro_is2025.parquet"
trade_out_csv = PROJECT / "studies" / "features" / "trade_features_gold_scalper_pro_is2025.csv"
trade_df.to_parquet(trade_out_parquet, index=False)
trade_df.to_csv(trade_out_csv, index=False)
print(f" → {trade_out_parquet.relative_to(PROJECT)} ({len(trade_df):,} rows)")
print(f" → {trade_out_csv.relative_to(PROJECT)}")
if not trade_df.empty:
wins = trade_df["label_win"].sum()
print(f" trades: {len(trade_df):,} wins: {wins} ({wins/len(trade_df):.1%}) "
f"avg pnl: ${trade_df['pnl'].mean():.3f}")
print(f" exit reasons:")
for r, n in trade_df["exit_reason"].value_counts().items():
sub = trade_df[trade_df["exit_reason"] == r]
print(f" {r:<14} {n:>5} ({n/len(trade_df):.1%}) "
f"avg_pnl=${sub['pnl'].mean():.3f} win_rate={sub['label_win'].mean():.1%}")
return 0
if __name__ == "__main__":
raise SystemExit(main())