feat(features): add hl_range + ret_intrabar OHLCV features (4-channel input)

- prepare_hourly.py: keep O/H/L columns from M1 zips; compute per-hour
  hl_range=log(H/L) and ret_intrabar=log(close/open); backward-compat
  (falls back to 4-col output only when O/H/L present in input)
- train.py build(): auto-detect extra features from parquet columns
  (FEAT_COLS = [ret, realized_vol] + [hl_range, ret_intrabar] if present)
- 5 new tests (9 total in test_prepare_hourly); 24/24 pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:11:59 +02:00
co-authored by Claude Sonnet 4.6
parent e635a641a4
commit caccd1aa7b
3 changed files with 122 additions and 14 deletions
+28 -10
View File
@@ -29,27 +29,45 @@ def resample_to_hourly(m1: pd.DataFrame) -> pd.DataFrame:
"""Aggregate M1 DataFrame to hourly bars.
Args:
m1: DataFrame with columns ['ts' (datetime), 'close' (float)]
m1: DataFrame with columns ['ts', 'open', 'high', 'low', 'close']
('open'/'high'/'low' optional — omit for close-only data).
Returns:
DataFrame with columns ['datetime', 'close', 'ret', 'realized_vol']
sorted by datetime; hours with fewer than MIN_BARS M1 ticks dropped.
DataFrame with columns ['datetime', 'close', 'ret', 'realized_vol',
'hl_range', 'ret_intrabar'] sorted by datetime.
Hours with fewer than MIN_BARS M1 ticks are dropped.
"""
m1 = m1.sort_values("ts").copy()
m1["log_r"] = np.log(m1["close"]).diff()
m1["hour"] = m1["ts"].dt.floor("h")
agg = m1.groupby("hour").agg(
close = ("close", "last"),
realized_vol= ("log_r", lambda x: np.sqrt(np.nansum(x.values ** 2))),
n_bars = ("log_r", "count"),
).reset_index()
has_ohlc = all(c in m1.columns for c in ("open", "high", "low"))
agg_dict = dict(
close = ("close", "last"),
realized_vol = ("log_r", lambda x: np.sqrt(np.nansum(x.values ** 2))),
n_bars = ("log_r", "count"),
)
if has_ohlc:
agg_dict["high"] = ("high", "max")
agg_dict["low"] = ("low", "min")
agg_dict["open_"] = ("open", "first")
agg = m1.groupby("hour").agg(**agg_dict).reset_index()
agg = agg[agg["n_bars"] >= MIN_BARS].copy()
agg["ret"] = np.log(agg["close"]).diff()
agg = agg.dropna(subset=["ret"]).reset_index(drop=True)
agg = agg.rename(columns={"hour": "datetime"})
return agg[["datetime", "close", "ret", "realized_vol"]]
if has_ohlc:
agg["hl_range"] = np.log(agg["high"] / agg["low"])
agg["ret_intrabar"]= np.log(agg["close"] / agg["open_"])
cols = ["datetime", "close", "ret", "realized_vol", "hl_range", "ret_intrabar"]
else:
cols = ["datetime", "close", "ret", "realized_vol"]
return agg[cols]
def load_m1_from_zips(raw_dir: str) -> pd.DataFrame:
@@ -68,7 +86,7 @@ def load_m1_from_zips(raw_dir: str) -> pd.DataFrame:
names=["dt", "open", "high", "low", "close", "vol"],
)
df["ts"] = pd.to_datetime(df["dt"], format="%Y%m%d %H%M%S")
frames.append(df[["ts", "close"]])
frames.append(df[["ts", "open", "high", "low", "close"]])
print(f" loaded {os.path.basename(zp)}: {len(df):,} rows")
return pd.concat(frames).sort_values("ts").reset_index(drop=True)