239 lines
8.6 KiB
Python
239 lines
8.6 KiB
Python
"""
|
||
AHAD QUANT Forex V5 — Sequence Dataset Preparation
|
||
Converts per-pair tabular features → sliding windows (B, SEQ_LEN, 62) for DL models.
|
||
|
||
Window i = X[i : i+SEQ_LEN] → sequence input for TFT / TransformerGRU
|
||
Label i = y[i+SEQ_LEN-1] → same label as the last step in the window
|
||
Tab row i = X[i+SEQ_LEN-1] → tabular features at the end of the window
|
||
(used to align tabular models in train.py)
|
||
|
||
Returned arrays are ALL aligned on the same M samples, making it trivial to
|
||
train tabular and DL models together with consistent train/val/test splits.
|
||
|
||
Usage (called from train.py when HAS_DL=True):
|
||
X_tab, X_seq, y = prepare_seq_dataset()
|
||
# X_tab : (M, 62) — same features as prepare_dataset(), but aligned
|
||
# X_seq : (M, 168, 62) — sequence tensors
|
||
# y : (M,) — labels
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
|
||
import numpy as np
|
||
|
||
import config
|
||
from features import build_features, NUM_FEATURES
|
||
|
||
# ─── Constants ───────────────────────────────────────────────────────────────
|
||
|
||
SEQ_LEN = 168 # 7 days × 24h — window length fed into TFT / TGRU
|
||
|
||
# Memory cap: 25 pairs × MAX_SEQ_PER_PAIR × 168 × 62 × 4B ~ 1.5 GB at 6000
|
||
# Increase if you have >16 GB RAM; decrease if training crashes with OOM.
|
||
MAX_SEQ_PER_PAIR: int = int(os.environ.get("MAX_SEQ_PER_PAIR", "6000"))
|
||
LOOKAHEAD = 3 # must match LOOKAHEAD in train.py
|
||
WARMUP = 30 # feature warm-up candles (same as train.py)
|
||
MIN_CANDLES = SEQ_LEN + LOOKAHEAD + WARMUP + 10 # minimum pair length
|
||
|
||
|
||
# ─── Core builders ───────────────────────────────────────────────────────────
|
||
|
||
def _load_candles(pair: str) -> list | None:
|
||
path = os.path.join(config.DATA_DIR, f"{pair}_1h.json")
|
||
if not os.path.exists(path):
|
||
return None
|
||
with open(path) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def _make_labels(close: np.ndarray, lookahead: int = LOOKAHEAD) -> np.ndarray:
|
||
labels = np.zeros(len(close))
|
||
for i in range(len(close) - lookahead):
|
||
labels[i] = 1.0 if close[i + lookahead] > close[i] else 0.0
|
||
return labels
|
||
|
||
|
||
def build_sequences(
|
||
X: np.ndarray,
|
||
y: np.ndarray,
|
||
seq_len: int = SEQ_LEN,
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||
"""
|
||
Convert a single pair's aligned (X, y) arrays into sliding windows.
|
||
|
||
Parameters
|
||
----------
|
||
X : (N, 62) feature matrix — already NaN-free and warmed up
|
||
y : (N,) label vector
|
||
seq_len : window length
|
||
|
||
Returns
|
||
-------
|
||
X_seq : (M, seq_len, 62) — sequences
|
||
X_tab : (M, 62) — tabular row at end of each window (aligned)
|
||
y_seq : (M,) — labels aligned with end of each window
|
||
|
||
where M = N - seq_len - LOOKAHEAD
|
||
(last LOOKAHEAD samples excluded to avoid future-leakage in labels)
|
||
"""
|
||
N = len(X)
|
||
# Last valid window ends at index N-LOOKAHEAD-1
|
||
n_windows = N - seq_len - LOOKAHEAD
|
||
if n_windows <= 0:
|
||
raise ValueError(
|
||
f"Not enough samples ({N}) for seq_len={seq_len} + lookahead={LOOKAHEAD}. "
|
||
f"Need at least {seq_len + LOOKAHEAD + 1}."
|
||
)
|
||
|
||
X_seq = np.empty((n_windows, seq_len, X.shape[1]), dtype=np.float32)
|
||
for i in range(n_windows):
|
||
X_seq[i] = X[i : i + seq_len]
|
||
|
||
# Tabular: last row of each window
|
||
X_tab = X[seq_len - 1 : seq_len - 1 + n_windows].astype(np.float32)
|
||
|
||
# Label: at the end of the window (same index as X_tab row)
|
||
y_seq = y[seq_len - 1 : seq_len - 1 + n_windows].astype(np.float32)
|
||
|
||
return X_seq, X_tab, y_seq
|
||
|
||
|
||
# ─── Full dataset builder ────────────────────────────────────────────────────
|
||
|
||
def prepare_seq_dataset(
|
||
seq_len: int = SEQ_LEN,
|
||
verbose: bool = True,
|
||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||
"""
|
||
Build the full aligned (X_tab, X_seq, y) dataset across all pairs.
|
||
|
||
Mirrors prepare_dataset() in train.py but:
|
||
1. Uses a sliding window of `seq_len` candles
|
||
2. Returns BOTH tabular (aligned) AND sequence data
|
||
3. All three output arrays share the same index space → consistent splits
|
||
|
||
Returns
|
||
-------
|
||
X_tab : (M_total, 62) — tabular features at end of each window
|
||
X_seq : (M_total, seq_len, 62) — sequence windows
|
||
y : (M_total,) — binary labels
|
||
"""
|
||
all_X_tab: list[np.ndarray] = []
|
||
all_X_seq: list[np.ndarray] = []
|
||
all_y: list[np.ndarray] = []
|
||
|
||
# Reference pair for correlation feature (same logic as train.py)
|
||
_ref = "EURUSD"
|
||
ref_data = _load_candles(_ref)
|
||
ref_close = np.array([c["c"] for c in ref_data]) if ref_data else None
|
||
|
||
for pair in config.COINS:
|
||
data = _load_candles(pair)
|
||
if data is None or len(data) < MIN_CANDLES:
|
||
if verbose:
|
||
print(f" [{pair:8s}] skipped — insufficient data ({len(data) if data else 0} candles)")
|
||
continue
|
||
|
||
close = np.array([c["c"] for c in data])
|
||
pair_ref = (
|
||
ref_close[-len(close) :]
|
||
if ref_close is not None and len(ref_close) >= len(close)
|
||
else None
|
||
)
|
||
|
||
X_all = build_features(data, btc_closes=pair_ref)
|
||
y_all = _make_labels(close)
|
||
|
||
# Apply same warm-up crop as train.py
|
||
X_all = X_all[WARMUP : -LOOKAHEAD]
|
||
y_all = y_all[WARMUP : -LOOKAHEAD]
|
||
|
||
# Drop NaN rows (should be none after warm-up, but safety check)
|
||
valid = ~np.isnan(X_all).any(axis=1)
|
||
X_all, y_all = X_all[valid], y_all[valid]
|
||
|
||
if len(X_all) < MIN_CANDLES:
|
||
if verbose:
|
||
print(f" [{pair:8s}] skipped after cleaning ({len(X_all)} samples left)")
|
||
continue
|
||
|
||
try:
|
||
X_seq_pair, X_tab_pair, y_seq_pair = build_sequences(X_all, y_all, seq_len)
|
||
except ValueError as e:
|
||
if verbose:
|
||
print(f" [{pair:8s}] skipped — {e}")
|
||
continue
|
||
|
||
# ── Memory cap: stratified sample to MAX_SEQ_PER_PAIR ──────────
|
||
n_pair = len(y_seq_pair)
|
||
if n_pair > MAX_SEQ_PER_PAIR:
|
||
rng = np.random.default_rng(seed=42)
|
||
# Preserve class balance (stratified)
|
||
idx_pos = np.where(y_seq_pair == 1)[0]
|
||
idx_neg = np.where(y_seq_pair == 0)[0]
|
||
half = MAX_SEQ_PER_PAIR // 2
|
||
sel_pos = rng.choice(idx_pos, size=min(half, len(idx_pos)), replace=False)
|
||
sel_neg = rng.choice(idx_neg, size=min(half, len(idx_neg)), replace=False)
|
||
sel = np.sort(np.concatenate([sel_pos, sel_neg]))
|
||
X_seq_pair = X_seq_pair[sel]
|
||
X_tab_pair = X_tab_pair[sel]
|
||
y_seq_pair = y_seq_pair[sel]
|
||
if verbose:
|
||
print(f" → sampled {len(y_seq_pair):,} / {n_pair:,} sequences (RAM cap)")
|
||
|
||
all_X_tab.append(X_tab_pair)
|
||
all_X_seq.append(X_seq_pair)
|
||
all_y.append(y_seq_pair)
|
||
|
||
if verbose:
|
||
print(f" [{pair:8s}] {len(y_seq_pair):>7,} sequences")
|
||
|
||
if not all_X_tab:
|
||
raise RuntimeError(
|
||
"No pairs produced sequences. Check DATA_DIR and MIN_CANDLES."
|
||
)
|
||
|
||
X_tab = np.concatenate(all_X_tab, axis=0)
|
||
X_seq = np.concatenate(all_X_seq, axis=0)
|
||
y = np.concatenate(all_y, axis=0)
|
||
|
||
if verbose:
|
||
print(f"\n Total : {len(y):,} sequences | shape X_seq={X_seq.shape} | "
|
||
f"{y.mean():.2%} long labels")
|
||
|
||
return X_tab, X_seq, y
|
||
|
||
|
||
# ─── Sequence normalisation helpers ─────────────────────────────────────────
|
||
|
||
def fit_seq_scaler(X_tab: np.ndarray):
|
||
"""
|
||
Fit a StandardScaler on tabular training data.
|
||
The same scaler is applied to sequences by reshaping (B, T, F) → (B*T, F).
|
||
Returns a fitted sklearn StandardScaler.
|
||
"""
|
||
from sklearn.preprocessing import StandardScaler
|
||
scaler = StandardScaler()
|
||
scaler.fit(X_tab)
|
||
return scaler
|
||
|
||
|
||
def transform_sequences(scaler, X_seq: np.ndarray) -> np.ndarray:
|
||
"""
|
||
Apply a fitted StandardScaler to a 3-D sequence array.
|
||
|
||
Parameters
|
||
----------
|
||
scaler : fitted sklearn StandardScaler
|
||
X_seq : (B, T, F) float32
|
||
|
||
Returns
|
||
-------
|
||
X_norm : (B, T, F) float32
|
||
"""
|
||
B, T, F = X_seq.shape
|
||
flat = X_seq.reshape(-1, F)
|
||
normed = scaler.transform(flat).astype(np.float32)
|
||
return normed.reshape(B, T, F)
|