Files
mt-data-mcp/research/feature_engineering.py
T
gavindiazandClaude Code 1bcfe6e374 feat(research): complete the offline pipeline and add live-identical backtest
research/ now runs end-to-end (fetch -> features/labels -> walk-forward ->
train -> OOS evaluate -> save), and backtest_strategy() replays the same
signal(ctx) strategy over a historical frame with the same manage_position
hook the live loop calls, simulating SL/TP, trailing, partial closes and cost
per bar. --backtest wires it into the CLI.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 20:20:49 +08:00

176 lines
6.7 KiB
Python

"""Feature engineering and forward (triple-barrier-style) label construction.
Indicator computation prefers ``pandas_ta`` (pandas-ta-classic) when installed;
every indicator has a small stdlib/numpy fallback so the pipeline runs on the
core dependency set. Labels are built forward-looking over ``label_horizon``
bars: +1 if take-profit hits first, -1 if stop-loss hits first, 0 otherwise
(same-bar policy: sl_first).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from .config import ResearchConfig
try: # pandas-ta-classic is not a core dependency; degrade gracefully.
import pandas_ta as ta # type: ignore
_PANDAS_TA = True
except ImportError:
_PANDAS_TA = False
# ---------------------------------------------------------------------------
# indicator fallbacks (pure numpy, close-based)
# ---------------------------------------------------------------------------
def _sma(values: np.ndarray, period: int) -> np.ndarray:
out = np.full(len(values), np.nan)
if len(values) < period:
return out
cs = np.cumsum(values)
out[period - 1 :] = (cs[period - 1 :] - np.concatenate([[0], cs[:-period]])) / period
return out
def _rsi(values: np.ndarray, period: int = 14) -> np.ndarray:
out = np.full(len(values), np.nan)
if len(values) <= period:
return out
delta = np.diff(values)
gain = np.where(delta > 0, delta, 0.0)
loss = np.where(delta < 0, -delta, 0.0)
avg_gain = np.full(len(delta), np.nan)
avg_loss = np.full(len(delta), np.nan)
avg_gain[period - 1] = gain[:period].mean()
avg_loss[period - 1] = loss[:period].mean()
for i in range(period, len(delta)):
avg_gain[i] = (avg_gain[i - 1] * (period - 1) + gain[i]) / period
avg_loss[i] = (avg_loss[i - 1] * (period - 1) + loss[i]) / period
with np.errstate(divide="ignore", invalid="ignore"):
rsi_series = 100 - 100 / (1 + avg_gain / avg_loss)
rsi_series[np.isnan(rsi_series)] = 100.0 # no losing bars -> 100, not NaN
out[period:] = rsi_series[period - 1 :]
return out
def _ema(values: np.ndarray, period: int) -> np.ndarray:
out = np.full(len(values), np.nan)
if len(values) < period:
return out
alpha = 2.0 / (period + 1)
out[period - 1] = values[:period].mean()
for i in range(period, len(values)):
out[i] = alpha * values[i] + (1 - alpha) * out[i - 1]
return out
def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
out = np.full(len(close), np.nan)
if len(close) <= period:
return out
prev = np.roll(close, 1)
prev[0] = close[0]
tr = np.maximum(high - low, np.maximum(np.abs(high - prev), np.abs(low - prev)))
out[period - 1] = tr[:period].mean()
for i in range(period, len(close)):
out[i] = (out[i - 1] * (period - 1) + tr[i]) / period
return out
# ---------------------------------------------------------------------------
# public builders
# ---------------------------------------------------------------------------
def build_features(frame: pd.DataFrame) -> pd.DataFrame:
"""Return a DataFrame with indicator columns aligned to the input rows."""
closes = frame["close"].to_numpy(dtype=float)
highs = frame["high"].to_numpy(dtype=float)
lows = frame["low"].to_numpy(dtype=float)
result = pd.DataFrame(index=frame.index)
if _PANDAS_TA:
result["rsi"] = ta.rsi(frame["close"], length=14).to_numpy()
result["sma20"] = ta.sma(frame["close"], length=20).to_numpy()
result["sma60"] = ta.sma(frame["close"], length=60).to_numpy()
result["ema12"] = ta.ema(frame["close"], length=12).to_numpy()
result["atr14"] = ta.atr(frame["high"], frame["low"], frame["close"], length=14).to_numpy()
bb = ta.bbands(frame["close"], length=20, std=2)
if bb is not None and not bb.empty:
result["bb_upper"] = bb["BBU_20_2.0"].to_numpy()
result["bb_lower"] = bb["BBL_20_2.0"].to_numpy()
else:
result["rsi"] = _rsi(closes, 14)
result["sma20"] = _sma(closes, 20)
result["sma60"] = _sma(closes, 60)
result["ema12"] = _ema(closes, 12)
result["atr14"] = _atr(highs, lows, closes, 14)
# simple derived features that cost nothing and are always available
result["ret_1"] = np.concatenate([[np.nan], np.diff(closes)]) / np.where(closes == 0, np.nan, closes)
result["ret_5"] = closes / np.roll(closes, 5) - 1
result["ret_20"] = closes / np.roll(closes, 20) - 1
result["range_pct"] = (highs - lows) / np.where(closes == 0, np.nan, closes)
return result
def build_labels(
frame: pd.DataFrame, config: ResearchConfig, direction: str = "long"
) -> pd.Series:
"""Forward triple-barrier-style labels aligned to each bar.
Entry is the bar's close. Looking ahead ``label_horizon`` bars: for long,
+1 when high >= entry * (1 + tp), -1 when low <= entry * (1 - sl); ties
resolve sl_first. Return NaN where not enough future bars exist.
"""
closes = frame["close"].to_numpy(dtype=float)
highs = frame["high"].to_numpy(dtype=float)
lows = frame["low"].to_numpy(dtype=float)
n = len(closes)
horizon = config.label_horizon
tp = config.label_tp_pct / 100.0
sl = config.label_sl_pct / 100.0
labels = np.full(n, np.nan)
up_level = 1.0 + tp if direction == "long" else 1.0 - tp
dn_level = 1.0 - sl if direction == "long" else 1.0 + sl
for i in range(n - horizon):
entry = closes[i]
if entry <= 0:
continue
window_high = highs[i + 1 : i + horizon + 1]
window_low = lows[i + 1 : i + horizon + 1]
hit_up = (window_high >= entry * up_level).any()
hit_dn = (window_low <= entry * dn_level).any()
if hit_dn and hit_up: # same-bar policy: sl_first
labels[i] = -1.0
elif hit_up:
labels[i] = 1.0
elif hit_dn:
labels[i] = -1.0
else:
labels[i] = 0.0
return pd.Series(labels, index=frame.index)
def make_dataset(frame: pd.DataFrame, config: ResearchConfig) -> tuple[pd.DataFrame, pd.Series]:
"""Combine features + labels into a supervised (X, y) pair, dropping warmup.
The target is collapsed to binary: ``1`` when take-profit hits first
(label +1) and ``0`` for stop-loss-first or timeout. The downstream
contract is "P(+1)" — a single probability drives the long-entry signal —
so ``predict_proba_positive`` is only meaningful on a binary target.
"""
feats = build_features(frame)
labels = build_labels(frame, config)
nans = feats.isna().any(axis=1) | labels.isna()
keep = ~nans
X = feats.loc[keep].reset_index(drop=True)
y = (labels.loc[keep] > 0).astype(int).reset_index(drop=True)
return X, y