SniperGold_ML/ml/build_features.py

365 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""
Algo Forge - Tahap 4 sub-sesi 2: build_features.py
====================================================
Bangun cache fitur untuk ML hibrida (LSTM 2-lapis + regime) pada data
XAUUSD M15 PADAT (2018+, ~186k bar) + konteks HTF (D1 18,7 th / H4 / H1).
- 19 fitur SMC: DIREUSE dari train_model.py (identik runtime-training).
- 6 fitur regime kausal: DIREUSE dari train_regime.py (t-GARCH sigma/ATR,
z_t, |z|, RV24/ATR, P(low), P(high)) — fit GARCH/HMM HANYA pada train
(<= split_bar); filter kausal.
- Label: forward 24 bar x 0.75 ATR (identik baseline), mask = window padat.
Output: Files\\AlgoForge\\Data\\features_XAUUSD.npz
F (n,19) label (n,) ATR (n,) close (n,) time (n,) F2 (n,6)
split_bar split_pos window_start config(JSON)
TIDAK menimpa features_XAUUSDc.npz (freeze Fase 3).
Cara pakai:
python build_features.py [--selftest] [--force]
"""
import os
import sys
import json
import math
import argparse
import datetime as dt
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.normpath(os.path.join(HERE, "..", "..", "SniperGold_ML"))
sys.path.insert(0, SRC)
import train_model as TM # noqa: E402 (fitur 19 + label + ATR)
import train_regime as TR # noqa: E402 (GARCH-t + HMM + RV24)
DATA = os.path.normpath(os.path.join(HERE, "..", "..", "..", "Files", "AlgoForge", "Data"))
OUT = os.path.join(DATA, "features_XAUUSD.npz")
WINDOW_START = "2018-01-01" # bar >= ini dianggap window PADAT (label/training)
WARMUP_START = "2017-01-01" # M15 mulai dihitung fiturnya (warmup >= 1 th)
WINSOR = 0.005 # clip return utk fit GARCH/HMM: 0.5% memberi interior klasik
# (alpha~0.06, beta~0.92, nu~7); 1% masih degenerasi (diag_garch2.py)
CONFIG = dict(
symbol="XAUUSD", tf="M15",
window_start=WINDOW_START, warmup_start=WARMUP_START,
H_LABEL=TM.H_LABEL, LABEL_ATR=TM.LABEL_ATR,
SWING_LEN=TM.SWING_LEN, INTERNAL_LEN=TM.INTERNAL_LEN,
ATR_PERIOD=TM.ATR_PERIOD, EQ_TOL_ATR=TM.EQ_TOL_ATR, EQ_BARS=TM.EQ_BARS,
GRAB_WINDOW=TM.GRAB_WINDOW, DELTA_BARS=TM.DELTA_BARS,
CONFLUENCE=TM.CONFLUENCE, n_features=19, n_regime=6,
regime_winsor=WINSOR,
note="fitur identik train_model.py; label di-mask ke window padat; "
"regime fit train-only + winsorize return (anti-lookahead ketat)",
)
def ts_utc(s):
return int(dt.datetime.strptime(s, "%Y-%m-%d").replace(
tzinfo=dt.timezone.utc).timestamp())
def garch_sigma_causal(r, w, a, b):
"""GARCH(1,1) sigma dgn inisialisasi K AUSAL (variansi unconditional dari
parameter yang di-fit train-only). Tidak seperti TR.garch_sigma yang memakai
np.var(r) seluruh array (lookahead halus di bar 0)."""
n = len(r)
s2 = np.empty(n)
s2[0] = w / max(1e-12, 1.0 - a - b)
for i in range(1, n):
s2[i] = w + a * r[i - 1] ** 2 + b * s2[i - 1]
return np.sqrt(np.maximum(s2, 1e-12))
GARCH_BOUNDS = [(1e-12, 1e-5), (0.001, 0.15), (0.80, 0.98), (2.1, 30.0)]
GARCH_X0 = [1e-7, 0.06, 0.92, 7.0]
def fit_garch_t_bounded(r, bounds=GARCH_BOUNDS, x0=GARCH_X0, maxiter=4000):
"""fit_garch_t dgn bounds KLASIK (alpha<=0.15, beta>=0.80) + iterasi lebih
banyak. Bounds original (alpha<=0.30, beta>=0.55) membiarkan MLE jatuh ke
solusi ARCH-heavy boundary (omega->0, alpha->0.3, beta->0.55, nu->30) yang
membuat z tidak ter-standardisasi & HMM kolaps."""
from scipy.optimize import minimize
rl = [float(x) for x in np.asarray(r)]
n = len(rl)
var0 = float(np.var(r) + 1e-12)
def negll(p):
w, a, b, nu = p
if w <= 0 or a < 0 or b < 0 or a + b >= 0.999 or nu <= 2.05:
return 1e12
l1 = math.log(math.pi * (nu - 2.0))
l2 = 2.0 * math.lgamma(nu / 2.0) - 2.0 * math.lgamma((nu + 1.0) / 2.0)
s2 = var0
ll = 0.0
for rv in rl:
s2 = w + a * rv * rv + b * s2
if s2 < 1e-12:
s2 = 1e-12
e2 = rv * rv / s2
ll += math.log(s2) + (nu + 1.0) * math.log1p(e2 / (nu - 2.0))
return 0.5 * (ll + n * (l1 + l2))
res = minimize(negll, x0, method="L-BFGS-B", bounds=bounds,
options=dict(maxiter=maxiter))
w, a, b, nu = res.x
return float(w), float(a), float(b), float(nu)
def build_regime_features_causal(close, atr, fit_start, split_bar, winsor=WINSOR):
"""Fitur rezim kausal (anti-lookahead ketat):
- fit GARCH-t HANYA pada rw[fit_start:split_bar+1] (padat-train),
bounds klasik + winsor 0.5% (robust thd outlier);
- sigma kausal (init unconditional);
- z = rw/sigma, di-STANDARISASI pada train (mean/std train-only) —
tanpa ini skala z tidak unit;
- HMM 2-state Gaussian (MSM) pada z, |z|, log(rv24), rv24 — SEMUA KOLAPS
(P(high)=1, state "bulk" menang di semua bar; bukti diag_hmm.py, konsisten
dgn pelajaran Fase 2). GANTI: regime score logistik kausal berbasis RV24
vs distribusi TRAIN (median/MAD) — proxy MSM yang stabil & anti-lookahead;
- RV24 dari return RAW (vol nyata).
"""
n = len(close)
r = np.zeros(n)
r[1:] = np.log(np.maximum(close[1:], 1e-12) / np.maximum(close[:-1], 1e-12))
rw = np.clip(r, -winsor, winsor)
w, a, b, nu = fit_garch_t_bounded(rw[fit_start:split_bar + 1])
sig = garch_sigma_causal(rw, w, a, b)
z = rw / np.maximum(sig, 1e-12)
ztr = z[fit_start:split_bar + 1]
zm, zs = float(ztr.mean()), float(ztr.std())
zs = zs if zs > 1e-9 else 1.0
z_std = (z - zm) / zs
r2 = r ** 2 # RV dari return RAW (vol nyata)
rv = np.zeros(n)
for i in range(23, n):
rv[i] = math.sqrt(float(r2[i - 23:i + 1].mean()))
# regime score kausal: P(high-vol) = sigmoid((rv24 - med_train)/MAD_train)
rv_tr = rv[fit_start:split_bar + 1]
med = float(np.median(rv_tr))
mad = float(np.median(np.abs(rv_tr - med))) + 1e-12
p_high = 1.0 / (1.0 + np.exp(-(rv - med) / (1.4826 * mad)))
p_low = 1.0 - p_high
atr_s = np.maximum(atr, 1e-12)
F2 = np.column_stack([sig / atr_s, z_std, np.abs(z_std), rv / atr_s,
p_low, p_high]).astype(float)
names = ["garch_sig_atr", "z_t", "z_abs", "rv24_atr", "p_lowvol", "p_highvol"]
info = dict(garch=(w, a, b, nu), rv_med=med, rv_mad=mad, p_high=p_high,
fit_start=fit_start, winsor=winsor, z_mean=zm, z_std=zs,
hmm_note="HMM 2-state Gaussian KOLAPS di feed ini (diag_hmm.py); "
"p_lowvol/p_highvol = regime score logistik kausal RV24")
return names, F2, info
def check_regime_sane(info, p_high, midx_split):
"""Sanity check anti-degenerasi: parameter GARCH tidak di bounds & P(high) variatif."""
w, a, b, nu = info["garch"]
at_bounds = (a >= 0.299 or b <= 0.551 or nu >= 29.9)
p_std = float(np.std(p_high))
p_const = p_std < 0.05
ok = (not at_bounds) and (not p_const)
print(f" [sanity] GARCH at-bounds={at_bounds} | P(high) std={p_std:.3f} "
f"const={p_const} -> {'OK' if ok else 'DEGENERATE'}")
return ok
def load_tf(name):
z = np.load(os.path.join(DATA, f"XAUUSD_{name}.npz"))
return (z["time"].astype(np.int64), z["open"].astype(np.float64),
z["high"].astype(np.float64), z["low"].astype(np.float64),
z["close"].astype(np.float64), z["tick_volume"].astype(np.float64))
def build_cache():
t, o, h, l, c, v = load_tf("M15")
keep = t >= ts_utc(WARMUP_START)
t, o, h, l, c, v = (t[keep], o[keep], h[keep], l[keep],
c[keep], v[keep])
n = len(c)
print(f"M15 window: n={n} | {dt.datetime.fromtimestamp(int(t[0]), dt.timezone.utc)} "
f".. {dt.datetime.fromtimestamp(int(t[-1]), dt.timezone.utc)}")
htf = {}
for key in ("D1", "H4", "H1"):
ht, hh, hl, hc, hv, _ = load_tf(key)
htf[key] = (hh, hl, hc, ht)
print("Menghitung 19 fitur SMC + label (train_model.build_features)...")
F, label, A, close = TM.build_features((t, o, h, l, c, v), htf)
print(f" F={F.shape} label={label.shape}")
# ---- window mask (label hanya di window padat) ----
win = t >= ts_utc(WINDOW_START)
mask = (label != 0) & win
midx = np.where(mask)[0]
n_bull = int((label[midx] == 1).sum())
n_bear = int((label[midx] == -1).sum())
print(f" window padat: bar={int(win.sum())} | berlabel={len(midx)} "
f"(bull={n_bull}, bear={n_bear})")
# ---- fitur regime (fit train-only pada window padat; anti-lookahead) ----
split_pos = int(0.75 * len(midx))
split_bar = int(midx[split_pos])
fit_start = int(np.argmax(t >= ts_utc(WINDOW_START)))
print(f" split walk-forward 75/25: split_pos={split_pos} split_bar={split_bar} "
f"({dt.datetime.fromtimestamp(int(t[split_bar]), dt.timezone.utc)})")
print(f" regime fit window: r[{fit_start}:{split_bar + 1}] "
f"(padat-train, n={split_bar + 1 - fit_start})")
names, F2, info = build_regime_features_causal(close, A, fit_start, split_bar)
w, a, b, nu = info["garch"]
print(f" t-GARCH: omega={w:.3e} alpha={a:.4f} beta={b:.4f} "
f"persist={a + b:.4f} nu={nu:.2f}")
ph = info["p_high"]
print(f" regime score (RV24 vs train-med): med={info['rv_med']:.2e} "
f"mad={info['rv_mad']:.2e}")
print(f" P(high-vol): mean={ph.mean():.3f} | test-mean="
f"{ph[midx[split_pos]:].mean():.3f} | std={np.std(ph):.3f}")
sane = check_regime_sane(info, ph, midx[split_pos:])
if not sane:
print(" !! REGIME DEGENERATE — periksa data/fit sebelum lanjut training.")
meta = dict(config=json.dumps(CONFIG),
names_regime=names, split_bar=int(split_bar),
split_pos=int(split_pos), n_labeled=int(len(midx)),
n_bull=int(n_bull), n_bear=int(n_bear),
window_start=WINDOW_START, warmup_start=WARMUP_START,
garch=(w, a, b, nu), rv_med=float(info["rv_med"]),
rv_mad=float(info["rv_mad"]), hmm_note=info["hmm_note"])
np.savez(OUT, F=F, label=label, ATR=A, close=close, time=t,
F2=F2, split_bar=split_bar, split_pos=split_pos,
meta=json.dumps(meta))
print(f"\nCache tersimpan: {OUT}")
return dict(n=n, n_labeled=len(midx), split_bar=split_bar,
split_pos=split_pos)
# ======================================================================
# UNIT TEST B1-B5 (anti-lookahead & kebenaran numerik)
# ======================================================================
def synth_series(nbars=30000, seed=11):
rng = np.random.default_rng(seed)
t = np.arange(nbars, dtype=np.int64) * 900 + ts_utc("2017-01-01")
base = 1000.0 + 8.0 * np.sin(np.arange(nbars) / 180.0)
o = base + rng.normal(0, 0.2, nbars)
c = base + rng.normal(0, 0.3, nbars)
h = np.maximum(o, c) + np.abs(rng.normal(0, 0.4, nbars))
l = np.minimum(o, c) - np.abs(rng.normal(0, 0.4, nbars))
v = np.full(nbars, 100.0)
return t, o, h, l, c, v
def agg_htf(t, o, h, l, c, v, step):
"""Agregasi sederhana M15 -> HTF (deterministik)."""
idx = t // step
out_t, out_o, out_h, out_l, out_c, out_v = [], [], [], [], [], []
for u in np.unique(idx):
m = idx == u
out_t.append(u * step)
out_o.append(o[m][0])
out_h.append(h[m].max())
out_l.append(l[m].min())
out_c.append(c[m][-1])
out_v.append(v[m].sum())
return (np.array(out_t, dtype=np.int64), np.array(out_o), np.array(out_h),
np.array(out_l), np.array(out_c), np.array(out_v))
def selftest():
ok = True
print("=== B1: ATR correctness (TR manual) ===")
h = np.array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24.0])
l = np.array([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23.0])
cc = np.array([9.5, 10.5, 11.5, 12.5, 13.5, 14.5, 15.5, 16.5, 17.5, 18.5,
19.5, 20.5, 21.5, 22.5, 23.5])
a = TM.atr_series(h, l, cc)
# ATR[13] = mean TR[0..13]; ATR bar awal (i<13) = h[i]-l[i] (sesuai implementasi)
trs = [h[0] - l[0]]
for i in range(1, 14):
trs.append(max(h[i] - l[i], abs(h[i] - cc[i - 1]), abs(l[i] - cc[i - 1])))
expect = float(np.mean(trs))
b1 = abs(a[13] - expect) < 1e-9 and abs(a[1] - (h[1] - l[1])) < 1e-9
print(f" ATR[13]={a[13]:.6f} expect={expect:.6f} | ATR[1]=h-l={a[1]:.6f} "
f"-> {'PASS' if b1 else 'FAIL'}")
ok &= b1
print("=== B2: anti-lookahead streaming fitur (prefix == full) ===")
t, o, h, l, c, v = synth_series(30000)
htf = {}
for key, st in (("D1", 86400), ("H4", 4 * 3600), ("H1", 3600)):
ht, hh, hl, hc, hv, _ = agg_htf(t, o, h, l, c, v, st)
htf[key] = (hh, hl, hc, ht)
Ff, _, _, _ = TM.build_features((t, o, h, l, c, v), htf)
P = 20000
Fp, _, _, _ = TM.build_features((t[:P], o[:P], h[:P], l[:P], c[:P], v[:P]), htf)
eq = np.array_equal(Ff[:P - 100], Fp[:P - 100])
print(f" Ff[:{P - 100}] == Fp[:{P - 100}] -> {'PASS' if eq else 'FAIL'}"
f" (max|d|={np.abs(Ff[:P - 100] - Fp[:P - 100]).max():.2e})")
ok &= eq
print("=== B3: label forward 24 x 0.75 ATR ===")
n3 = 60
t3 = np.arange(n3, dtype=np.int64) * 900
o3 = np.full(n3, 100.0)
h3 = np.full(n3, 101.0)
l3 = np.full(n3, 99.0)
c3 = np.linspace(100.0, 130.0, n3) # naik konstan
v3 = np.full(n3, 10.0)
htf3 = {}
for key, st in (("D1", 86400), ("H4", 4 * 3600), ("H1", 3600)):
ht, hh, hl, hc, hv, _ = agg_htf(t3, o3, h3, l3, c3, v3, st)
htf3[key] = (hh, hl, hc, ht)
F3, lab3, A3, _ = TM.build_features((t3, o3, h3, l3, c3, v3), htf3)
thr0 = 0.75 * A3[0]
expect0 = 1 if (c3[24] - c3[0]) >= thr0 else (-1 if (c3[24] - c3[0]) <= -thr0 else 0)
b3 = lab3[0] == expect0 and lab3[n3 - 1] == 0
print(f" lab[0]={lab3[0]} expect={expect0} (fwd={(c3[24] - c3[0]):.2f}, "
f"thr={thr0:.3f}) -> {'PASS' if b3 else 'FAIL'}")
ok &= b3
print("=== B4: GARCH sigma kausal (truncation invariance) ===")
rng = np.random.default_rng(5)
r = rng.normal(0, 1, 600)
w, a4, b4, nu = 1e-6, 0.08, 0.90, 7.0
sig_full = garch_sigma_causal(r, w, a4, b4)
sig_pref = garch_sigma_causal(r[:400], w, a4, b4)
b4_ok = np.allclose(sig_full[:400], sig_pref, atol=1e-12)
print(f" sigma[:400] identik (init unconditional, kausal) "
f"-> {'PASS' if b4_ok else 'FAIL'}")
ok &= b4_ok
print("=== B5: HMM filter kausal + valid prob ===")
z = np.concatenate([rng.normal(0, 0.5, 300), rng.normal(0, 2.0, 300)])
A, pi, mu, var, ll = TR.hmm_em(z[:400], K=2, iters=8)
p_full = TR.hmm_filter(z, A, pi, mu, var)
p_pref = TR.hmm_filter(z[:400], A, pi, mu, var)
in01 = bool((p_full >= 0).all() and (p_full <= 1).all())
sums = np.abs(p_full.sum(1) - 1.0).max()
caus = np.allclose(p_full[:400], p_pref, atol=1e-10)
b5 = in01 and sums < 1e-9 and caus
print(f" p in [0,1]={in01} | max|sum-1|={sums:.2e} | "
f"p[:400] kausal={'PASS' if caus else 'FAIL'}")
ok &= b5
print("\n=== SELFTEST B1-B5:", "PASS" if ok else "FAIL", "===")
return ok
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
if args.selftest:
sys.exit(0 if selftest() else 1)
if os.path.exists(OUT) and not args.force:
print(f"Cache sudah ada: {OUT}\nGunakan --force untuk membangun ulang.")
return
build_cache()
print("Selesai.")
if __name__ == "__main__":
main()