2026-08-24 19:50:21 +07:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""R1 — Real-data transfer gate (PRIMARY + ROBUSTNESS).
|
|
|
|
|
|
|
|
|
|
R1-A: XAUUSDc M1 H=15 (~15 min)
|
|
|
|
|
R1-B: XAUUSDc M5 H=3 (~15 min)
|
|
|
|
|
R1-C: XAUUSDc M15 H=1 (~15 min)
|
|
|
|
|
|
|
|
|
|
Models: Naive / Drift / ARIMA (frozen p=1,d=0,q=0) / SAX (frozen cfg) / Hybrid.
|
|
|
|
|
|
|
|
|
|
FROZEN configuration (configs/default.json), zero parameter optimization.
|
|
|
|
|
Chronological walk-forward with pre-registered stride sampling (tractability only).
|
|
|
|
|
No-lookahead: SAX candidates end <= origin - H; outcomes revealed after freeze.
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
Fast SAX path is numerically validated against the frozen SaxAnalogForecaster
|
|
|
|
|
(both z-normalize candidate windows after DEFECT-001 fix). Forecast origins
|
|
|
|
|
that would cross a long (weekend/holiday) gap in [origin, origin+H] are excluded.
|
2026-08-24 19:50:21 +07:00
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
Cost (real-data): roundtrip = mean broker spread (real spread field, point=0.01)
|
|
|
|
|
+ 2x documented commission; converted to ATR units via price/ATR.
|
2026-08-24 19:50:21 +07:00
|
|
|
Gross and net (cost-adjusted) results reported separately.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
import argparse
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
from src.forecasting.target import ForecastContext, data_atr
|
|
|
|
|
from src.forecasting.interface import Series
|
|
|
|
|
from src.arima import ArimaConfig, ArimaModel
|
|
|
|
|
from src.sax import SaxConfig, SaxAnalogForecaster
|
2026-08-24 21:32:58 +07:00
|
|
|
from src.sax.transform import sax_encode, z_normalize
|
2026-08-24 19:50:21 +07:00
|
|
|
from src.pipeline import build_models, build_hybrid_and_filters
|
|
|
|
|
from src.evaluation import metrics as M
|
|
|
|
|
from src.forecasting.record import ForecastRecord, ModelState
|
|
|
|
|
|
|
|
|
|
RAW = "results/R1_real_data/XAUUSDc_M1_raw.json"
|
|
|
|
|
PROTOCOL_VERSION = "r1-v0.1.0-frozen"
|
|
|
|
|
OUT_ROOT = "results"
|
|
|
|
|
COMMISSION_BPS_PER_SIDE = 3.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_raw(path):
|
|
|
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
|
|
|
return json.load(fh)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sorted_unique(bars):
|
|
|
|
|
seen = {}
|
|
|
|
|
for b in bars:
|
2026-08-24 21:32:58 +07:00
|
|
|
seen[b.get("timestamp") or b["time"]] = b
|
|
|
|
|
return [seen[k] for k in sorted(seen.keys())]
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def mk_series(sub, symbol, timeframe):
|
|
|
|
|
return Series(symbol=symbol, timeframe=timeframe,
|
|
|
|
|
timestamp=[(b.get("timestamp") or b["time"]) for b in sub],
|
|
|
|
|
open=[float(b["open"]) for b in sub],
|
|
|
|
|
high=[float(b["high"]) for b in sub],
|
|
|
|
|
low=[float(b["low"]) for b in sub],
|
|
|
|
|
close=[float(b["close"]) for b in sub])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def aggregate(sub, k):
|
|
|
|
|
out, buf = [], []
|
|
|
|
|
for b in sub:
|
|
|
|
|
buf.append(b)
|
|
|
|
|
if len(buf) == k:
|
|
|
|
|
out.append({"timestamp": (buf[-1].get("timestamp") or buf[-1]["time"]),
|
|
|
|
|
"open": float(buf[0]["open"]),
|
|
|
|
|
"high": max(float(x["high"]) for x in buf),
|
|
|
|
|
"low": min(float(x["low"]) for x in buf),
|
|
|
|
|
"close": float(buf[-1]["close"]),
|
2026-08-24 21:32:58 +07:00
|
|
|
"spread": (float(np.nanmean([float(x["spread"]) for x in buf if x.get("spread") is not None]))
|
|
|
|
|
if any(x.get("spread") is not None for x in buf) else None)})
|
2026-08-24 19:50:21 +07:00
|
|
|
buf = []
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
def valid_origins_arr(times, H, tf_minutes=1):
|
|
|
|
|
"""Mask[o] True => bars o..o+H are contiguous at the resolution's step."""
|
2026-08-24 19:50:21 +07:00
|
|
|
dtv = [datetime.strptime(t, "%Y.%m.%d %H:%M:%S") for t in times]
|
|
|
|
|
deltas = np.zeros(len(times), float)
|
|
|
|
|
for i in range(1, len(times)):
|
|
|
|
|
deltas[i] = (dtv[i] - dtv[i - 1]).total_seconds() / 60.0
|
2026-08-24 21:32:58 +07:00
|
|
|
contig = deltas[1:] == float(tf_minutes)
|
2026-08-24 19:50:21 +07:00
|
|
|
n = len(contig)
|
|
|
|
|
if n < H:
|
|
|
|
|
return np.zeros(len(times) - H, bool)
|
|
|
|
|
win = np.lib.stride_tricks.sliding_window_view(contig, H)
|
2026-08-24 21:32:58 +07:00
|
|
|
return win.all(axis=1)
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Fast, equivalence-validated SAX path (vectorized; matches frozen after fix)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-08-24 19:50:21 +07:00
|
|
|
def precompute_words(close, W, word_len, alpha):
|
2026-08-24 21:32:58 +07:00
|
|
|
"""sym_idx[i] = int-symbol word for window ending at index i + W - 1; mid[a] = midpoint."""
|
|
|
|
|
from src.sax.transform import _bp_for
|
|
|
|
|
bp = _bp_for(alpha)
|
|
|
|
|
bounds = np.concatenate(([-2.0], bp, [2.0]))
|
|
|
|
|
mid = (bounds[:-1] + bounds[1:]) / 2.0
|
|
|
|
|
n_w = len(close) - W + 1
|
|
|
|
|
sym_idx = np.zeros((n_w, word_len), dtype=np.int32)
|
|
|
|
|
for j in range(n_w):
|
|
|
|
|
q = z_normalize(close[j: j + W])
|
|
|
|
|
out = sax_encode(q, word_len, alpha)
|
|
|
|
|
sym_idx[j] = [ord(ch) - ord("a") for ch in out]
|
|
|
|
|
return sym_idx, mid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fast_sax_record(series, origin, ctx, sax_cfg, sym_idx, mid):
|
2026-08-24 19:50:21 +07:00
|
|
|
close = series.closes()
|
|
|
|
|
W = sax_cfg.window_length
|
2026-08-24 21:32:58 +07:00
|
|
|
alpha = sax_cfg.alphabet_size
|
2026-08-24 19:50:21 +07:00
|
|
|
H = ctx.horizon
|
|
|
|
|
rec = ForecastRecord(symbol=ctx.symbol, timeframe=ctx.timeframe, horizon=H,
|
|
|
|
|
variant="C_sax", model_id="C_sax", model_version="0.1.0",
|
|
|
|
|
forecast_origin=origin, outcome_boundary=origin + H)
|
|
|
|
|
if origin + 1 < W:
|
|
|
|
|
rec.sax_state, rec.sax_analog_count = ModelState.INSUFFICIENT.value, 0
|
|
|
|
|
return rec
|
|
|
|
|
q = z_normalize(close[origin - W + 1: origin + 1])
|
2026-08-24 21:32:58 +07:00
|
|
|
q_word = sax_encode(q, sax_cfg.word_length, alpha)
|
2026-08-24 19:50:21 +07:00
|
|
|
rec.sax_word = q_word
|
2026-08-24 21:32:58 +07:00
|
|
|
q_idx = np.asarray([ord(ch) - ord("a") for ch in q_word], np.int32)
|
2026-08-24 19:50:21 +07:00
|
|
|
last_allowed = origin - H
|
|
|
|
|
if last_allowed < W - 1:
|
|
|
|
|
rec.sax_state, rec.sax_analog_count = ModelState.INSUFFICIENT.value, 0
|
|
|
|
|
return rec
|
2026-08-24 21:32:58 +07:00
|
|
|
cand = sym_idx[:last_allowed - (W - 1) + 1]
|
|
|
|
|
Mdiff = np.abs(mid[None, :] - mid[:, None]).astype(float)
|
|
|
|
|
dist = np.zeros(cand.shape[0])
|
|
|
|
|
for w in range(sax_cfg.word_length):
|
|
|
|
|
dist += Mdiff[q_idx[w], cand[:, w]]
|
|
|
|
|
idx = np.where(dist <= sax_cfg.distance_threshold)[0]
|
2026-08-24 19:50:21 +07:00
|
|
|
if len(idx) < sax_cfg.min_analogs:
|
|
|
|
|
rec.sax_state, rec.sax_analog_count = ModelState.INSUFFICIENT.value, 0
|
|
|
|
|
return rec
|
|
|
|
|
k = min(sax_cfg.top_k_analogs, len(idx))
|
2026-08-24 21:32:58 +07:00
|
|
|
order = np.argsort(dist[idx])[:k]
|
|
|
|
|
sel_rows = idx[order]
|
|
|
|
|
sel_j = [(W - 1) + int(r) for r in sel_rows]
|
2026-08-24 19:50:21 +07:00
|
|
|
atr = data_atr(close, ctx.atr_period)
|
|
|
|
|
outcomes = np.asarray([(close[j + H] - close[j]) / atr[j] for j in sel_j], float)
|
|
|
|
|
rec.sax_analog_count = int(len(outcomes))
|
|
|
|
|
rec.sax_distance_metric = "midpoint_mindist"
|
2026-08-24 21:32:58 +07:00
|
|
|
rec.sax_distance_median = float(np.median(dist[sel_rows]))
|
2026-08-24 19:50:21 +07:00
|
|
|
rec.sax_median = float(np.median(outcomes))
|
|
|
|
|
rec.sax_P25 = float(np.percentile(outcomes, 25))
|
|
|
|
|
rec.sax_P75 = float(np.percentile(outcomes, 75))
|
|
|
|
|
rec.sax_up_rate = float(np.mean(outcomes > 0))
|
|
|
|
|
med = rec.sax_median
|
|
|
|
|
thr = sax_cfg.neutral_threshold
|
|
|
|
|
if med > thr:
|
|
|
|
|
rec.sax_state = ModelState.BULLISH.value
|
|
|
|
|
elif med < -thr:
|
|
|
|
|
rec.sax_state = ModelState.BEARISH.value
|
|
|
|
|
else:
|
|
|
|
|
rec.sax_state = ModelState.NO_EDGE.value if abs(rec.sax_up_rate - 0.5) <= 0.10 else ModelState.NEUTRAL.value
|
|
|
|
|
rec.normalized_expected_return = rec.sax_median
|
|
|
|
|
rec.forecast_direction = {"bullish": "LONG", "bearish": "SHORT"}.get(rec.sax_state, "NEUTRAL")
|
|
|
|
|
rec.confidence = float(abs(rec.sax_up_rate - 0.5))
|
|
|
|
|
rec.uncertainty = float(rec.sax_P75 - rec.sax_P25) if len(outcomes) >= 2 else None
|
|
|
|
|
return rec
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
def validate_fast_sax(series, ctx, sax_cfg, sym_idx, mid, n=40, seed=7, cap=8000):
|
|
|
|
|
"""Equivalence validation on a BOUNDED prefix (keeps frozen python scan fast)."""
|
|
|
|
|
full_close = series.closes()
|
|
|
|
|
bound = min(cap, len(full_close))
|
|
|
|
|
s_small = Series(symbol=series.symbol, timeframe=series.timeframe,
|
|
|
|
|
timestamp=list(series.timestamp[:bound]),
|
|
|
|
|
open=list(series.open[:bound]), high=list(series.high[:bound]),
|
|
|
|
|
low=list(series.low[:bound]), close=list(series.close[:bound]))
|
2026-08-24 19:50:21 +07:00
|
|
|
frozen = SaxAnalogForecaster(sax_cfg)
|
|
|
|
|
rng = np.random.default_rng(seed)
|
2026-08-24 21:32:58 +07:00
|
|
|
lo = sax_cfg.window_length + ctx.horizon
|
|
|
|
|
pool = np.arange(lo, bound - ctx.horizon)
|
|
|
|
|
origins = rng.choice(pool, size=min(n, len(pool)), replace=False)
|
2026-08-24 19:50:21 +07:00
|
|
|
for o in origins:
|
|
|
|
|
o = int(o)
|
2026-08-24 21:32:58 +07:00
|
|
|
a = frozen.forecast(s_small, o, ctx, "", "")
|
|
|
|
|
b = fast_sax_record(s_small, o, ctx, sax_cfg, sym_idx, mid)
|
2026-08-24 19:50:21 +07:00
|
|
|
da, db = a.to_dict(), b.to_dict()
|
|
|
|
|
for key in ("sax_state", "sax_analog_count", "sax_median", "sax_P25",
|
|
|
|
|
"sax_P75", "sax_up_rate", "sax_word", "forecast_direction",
|
|
|
|
|
"normalized_expected_return", "sax_distance_median"):
|
|
|
|
|
va, vb = da.get(key), db.get(key)
|
|
|
|
|
if isinstance(va, float) and isinstance(vb, float):
|
|
|
|
|
if not (abs(va - vb) < 1e-9 or (va != va and vb != vb)):
|
|
|
|
|
raise AssertionError(f"fast SAX mismatch origin {o} {key}: {va} vs {vb}")
|
|
|
|
|
elif va != vb:
|
|
|
|
|
raise AssertionError(f"fast SAX mismatch origin {o} {key}: {va} vs {vb}")
|
2026-08-24 21:32:58 +07:00
|
|
|
print(f" [eq-check] fast SAX == frozen SAX on {len(origins)} origins (prefix {bound}): OK")
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# evaluation
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-08-24 19:50:21 +07:00
|
|
|
def eval_study(recs, atr_map, rt_bps, mult=1.0):
|
|
|
|
|
rows = []
|
|
|
|
|
for r in recs:
|
|
|
|
|
y = r.actual_forward_return_ATR
|
|
|
|
|
d = r.forecast_direction
|
|
|
|
|
trial = d in ("LONG", "SHORT")
|
|
|
|
|
sgn = 1.0 if d == "LONG" else (-1.0 if d == "SHORT" else 0.0)
|
|
|
|
|
cost = (rt_bps * mult / 10000.0) * atr_map.get(r.forecast_origin, 1.0) if trial else 0.0
|
|
|
|
|
gross = (sgn * y if y is not None else 0.0) if trial else 0.0
|
|
|
|
|
rows.append({"origin": r.forecast_origin, "y": y, "pred": r.normalized_expected_return,
|
|
|
|
|
"dir": d, "trial": trial, "gross": gross, "cost": cost,
|
|
|
|
|
"net": gross - cost if trial else 0.0})
|
|
|
|
|
df = pd.DataFrame(rows)
|
2026-08-24 21:32:58 +07:00
|
|
|
if df.empty:
|
|
|
|
|
row0 = {"n_total": 0, "n_trial": 0, "coverage": np.nan, "mae": np.nan, "rmse": np.nan,
|
|
|
|
|
"bias": np.nan, "dir_acc": np.nan, "gross_exp": np.nan, "net_exp": np.nan,
|
|
|
|
|
"gross_sum": 0.0, "net_sum": 0.0, "profit_factor": np.nan,
|
|
|
|
|
"max_drawdown": np.nan, "sharpe": np.nan}
|
|
|
|
|
return row0, df
|
2026-08-24 19:50:21 +07:00
|
|
|
out = {"n_total": int(len(df)), "n_trial": int(df["trial"].sum()),
|
|
|
|
|
"coverage": float(df["trial"].mean()) if len(df) else np.nan}
|
|
|
|
|
d = df[df["pred"].notna() & df["y"].notna()]
|
|
|
|
|
out["mae"] = M.mae(d.y.to_numpy(), d.pred.to_numpy()) if len(d) else np.nan
|
|
|
|
|
out["rmse"] = M.rmse(d.y.to_numpy(), d.pred.to_numpy()) if len(d) else np.nan
|
|
|
|
|
out["bias"] = float(np.mean(d.pred.to_numpy() - d.y.to_numpy())) if len(d) else np.nan
|
|
|
|
|
t = df[df["trial"] & df["y"].notna()]
|
|
|
|
|
if len(t):
|
|
|
|
|
yv = t["y"].to_numpy()
|
2026-08-24 21:32:58 +07:00
|
|
|
out["dir_acc"] = float((((t["dir"] == "LONG") & (yv > 0)) | ((t["dir"] == "SHORT") & (yv < 0))).mean())
|
2026-08-24 19:50:21 +07:00
|
|
|
out["gross_exp"] = float(np.mean(t["gross"]))
|
|
|
|
|
out["net_exp"] = float(np.mean(t["net"]))
|
|
|
|
|
out["gross_sum"] = float(np.sum(t["gross"]))
|
|
|
|
|
out["net_sum"] = float(np.sum(t["net"]))
|
|
|
|
|
out["profit_factor"] = M.profit_factor(t["net"].tolist())
|
|
|
|
|
out["max_drawdown"] = M.max_drawdown(t["net"].tolist())
|
|
|
|
|
out["sharpe"] = M.sharpe(t["net"].tolist(), scale=1.0)
|
|
|
|
|
else:
|
|
|
|
|
for k in ("dir_acc", "gross_exp", "net_exp", "gross_sum", "net_sum",
|
|
|
|
|
"profit_factor", "max_drawdown", "sharpe"):
|
|
|
|
|
out[k] = np.nan
|
|
|
|
|
return out, df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bootstrap_ci(diffs, B=2000, seed=42):
|
|
|
|
|
diffs = np.asarray(diffs, float)
|
|
|
|
|
diffs = diffs[np.isfinite(diffs)]
|
|
|
|
|
if diffs.size == 0:
|
|
|
|
|
return None
|
|
|
|
|
rng = np.random.default_rng(seed)
|
|
|
|
|
means = np.array([np.mean(rng.choice(diffs, size=diffs.size, replace=True)) for _ in range(B)])
|
|
|
|
|
return [float(np.mean(diffs)), float(np.percentile(means, 2.5)), float(np.percentile(means, 97.5))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean(o):
|
|
|
|
|
return {k: (None if (isinstance(v, float) and v != v) else (round(v, 6) if isinstance(v, float) else v))
|
|
|
|
|
for k, v in o.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
ap = argparse.ArgumentParser()
|
|
|
|
|
ap.add_argument("--stride-m1", type=int, default=60)
|
|
|
|
|
ap.add_argument("--stride-m5", type=int, default=12)
|
|
|
|
|
ap.add_argument("--stride-m15", type=int, default=4)
|
|
|
|
|
ap.add_argument("--cap", type=int, default=0)
|
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
|
|
|
bars = sorted_unique(load_raw(RAW))
|
|
|
|
|
if args.cap and len(bars) > args.cap:
|
|
|
|
|
bars = bars[-args.cap:]
|
|
|
|
|
print(f"unique ordered M1 bars: {len(bars)}")
|
|
|
|
|
|
|
|
|
|
sub5 = aggregate(bars, 5)
|
|
|
|
|
sub15 = aggregate(bars, 15)
|
|
|
|
|
close0 = np.asarray([float(b["close"]) for b in bars])
|
|
|
|
|
hash1 = hashlib.sha256(close0.astype("<f8").tobytes()).hexdigest()
|
|
|
|
|
hash5 = hashlib.sha256(np.asarray([b["close"] for b in sub5], float).astype("<f8").tobytes()).hexdigest()
|
|
|
|
|
hash15 = hashlib.sha256(np.asarray([b["close"] for b in sub15], float).astype("<f8").tobytes()).hexdigest()
|
|
|
|
|
|
|
|
|
|
with open("configs/default.json", "r", encoding="utf-8") as fh:
|
|
|
|
|
cfg = json.load(fh)
|
|
|
|
|
arima_cfg = ArimaConfig(**cfg["arima"])
|
|
|
|
|
sax_cfg = SaxConfig(**cfg["sax"])
|
|
|
|
|
c_hash = hashlib.sha256(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:16]
|
|
|
|
|
|
|
|
|
|
spread_price = np.asarray([float((b.get("spread") or 0) * 0.01) for b in bars], float)
|
|
|
|
|
mean_spread_bps = float(np.mean(spread_price) / np.mean(close0) * 10000.0)
|
|
|
|
|
rt_bps = mean_spread_bps + 2.0 * COMMISSION_BPS_PER_SIDE
|
|
|
|
|
|
|
|
|
|
protocols = {
|
|
|
|
|
"M1": {"s": mk_series(bars, "XAUUSDc", "M1"), "H": 15, "train": 2000, "stride": args.stride_m1, "atr": 20},
|
|
|
|
|
"M5": {"s": mk_series(sub5, "XAUUSDc", "M5"), "H": 3, "train": 2000, "stride": args.stride_m5, "atr": 20},
|
|
|
|
|
"M15": {"s": mk_series(sub15, "XAUUSDc", "M15"), "H": 1, "train": 500, "stride": args.stride_m15, "atr": 20},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
manifest = {
|
|
|
|
|
"experiment_id": "R1", "protocol_version": PROTOCOL_VERSION, "config_hash": c_hash,
|
2026-08-24 21:32:58 +07:00
|
|
|
"defect_fix": "DEFECT-001 fixed in ff95e11 (SAX candidates z-normalized); "
|
|
|
|
|
"R1 re-run from scratch with corrected component",
|
2026-08-24 19:50:21 +07:00
|
|
|
"arima": cfg["arima"], "sax": cfg["sax"], "hybrid": cfg["hybrid"],
|
|
|
|
|
"dataset": {"symbol": "XAUUSDc", "source": "MT5 terminal history",
|
|
|
|
|
"note": "m5/m15 aggregated from M1 (index-based); timezone server trade time",
|
2026-08-24 21:32:58 +07:00
|
|
|
"bars": {"M1": len(bars), "M5": len(sub5), "M15": len(sub15)},
|
2026-08-24 19:50:21 +07:00
|
|
|
"hashes": {"M1": hash1, "M5": hash5, "M15": hash15},
|
|
|
|
|
"integrity": {"ohlc_violations": 0, "nonfinite": 0, "negative_spread": 0}},
|
|
|
|
|
"cost": {"rt_bps": round(rt_bps, 3), "mean_spread_bps": round(mean_spread_bps, 3),
|
|
|
|
|
"commission_bps_per_side": COMMISSION_BPS_PER_SIDE,
|
|
|
|
|
"spread_source": "real broker spread field (points*0.01)",
|
2026-08-24 21:32:58 +07:00
|
|
|
"note": "gross vs net separated"},
|
|
|
|
|
}
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
all_results = {}
|
|
|
|
|
all_recs = {}
|
|
|
|
|
all_atrmap = {}
|
|
|
|
|
|
|
|
|
|
for tf, p in protocols.items():
|
|
|
|
|
s, H, tr, stride, atrp = p["s"], p["H"], p["train"], p["stride"], p["atr"]
|
|
|
|
|
ctx = ForecastContext(symbol="XAUUSDc", timeframe=tf, horizon=H, atr_period=atrp)
|
|
|
|
|
close = s.closes()
|
|
|
|
|
atr = data_atr(close, atrp)
|
|
|
|
|
atr_map = {i: (float(close[i]) / float(atr[i])) if atr[i] > 0 else 1.0 for i in range(len(close))}
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
vmask = valid_origins_arr(list(s.timestamp), H, tf_minutes={"M1": 1, "M5": 5, "M15": 15}[tf])
|
|
|
|
|
origins_cand = [o for o in range(tr, len(close) - H) if vmask[o]]
|
2026-08-24 19:50:21 +07:00
|
|
|
origins = origins_cand[::stride]
|
2026-08-24 21:32:58 +07:00
|
|
|
print(f"[{tf}] H={H} train={tr} valid origins={len(origins_cand)} sampled={len(origins)}")
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
naive = build_models(arima_cfg, sax_cfg)["A_naive"]
|
|
|
|
|
drift = build_models(arima_cfg, sax_cfg)["A_naive_drift"]
|
|
|
|
|
arima = ArimaModel(arima_cfg)
|
|
|
|
|
|
2026-08-24 21:32:58 +07:00
|
|
|
sym_idx, mid = precompute_words(close, sax_cfg.window_length, sax_cfg.word_length, sax_cfg.alphabet_size)
|
2026-08-24 19:50:21 +07:00
|
|
|
if tf == "M1":
|
2026-08-24 21:32:58 +07:00
|
|
|
validate_fast_sax(s, ctx, sax_cfg, sym_idx, mid)
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
recs_naive = [naive.forecast(s, o, ctx, c_hash, hash1) for o in origins]
|
|
|
|
|
recs_drift = [drift.forecast(s, o, ctx, c_hash, hash1) for o in origins]
|
|
|
|
|
recs_arima = [arima.forecast(s, o, ctx, c_hash, hash1) for o in origins]
|
2026-08-24 21:32:58 +07:00
|
|
|
recs_sax = [fast_sax_record(s, o, ctx, sax_cfg, sym_idx, mid) for o in origins]
|
2026-08-24 19:50:21 +07:00
|
|
|
|
|
|
|
|
for r in recs_naive + recs_drift + recs_arima + recs_sax:
|
|
|
|
|
o = r.forecast_origin
|
|
|
|
|
r.actual_forward_return = float(close[o + H] - close[o])
|
|
|
|
|
r.actual_forward_return_ATR = float(close[o + H] - close[o]) / atr[o] if atr[o] > 0 else 0.0
|
|
|
|
|
r.outcome_boundary = o + H
|
|
|
|
|
r.train_boundary = o
|
|
|
|
|
r.actual_outcome_timestamp = s.timestamp[o + H]
|
|
|
|
|
r.prediction_timestamp = s.timestamp[o]
|
|
|
|
|
|
|
|
|
|
hybrid = build_hybrid_and_filters(recs_arima, recs_sax)["D_hybrid"]
|
|
|
|
|
vmap = {"naive": recs_naive, "drift": recs_drift,
|
|
|
|
|
"arima": recs_arima, "sax": recs_sax, "hybrid": hybrid}
|
|
|
|
|
all_recs[tf] = vmap
|
|
|
|
|
all_atrmap[tf] = atr_map
|
|
|
|
|
|
|
|
|
|
eval_res = {}
|
|
|
|
|
for name, rr in vmap.items():
|
2026-08-24 21:32:58 +07:00
|
|
|
eval_res[name], _ = eval_study(rr, atr_map, rt_bps)
|
|
|
|
|
|
2026-08-24 19:50:21 +07:00
|
|
|
byo = {name: {r.forecast_origin: r for r in rr} for name, rr in vmap.items()}
|
|
|
|
|
paired = {}
|
|
|
|
|
for a, b in [("arima", "naive"), ("sax", "naive"), ("hybrid", "naive"),
|
|
|
|
|
("hybrid", "arima"), ("hybrid", "sax")]:
|
|
|
|
|
common = sorted(set(byo[a]) & set(byo[b]))
|
|
|
|
|
nets, mae_diffs = [], []
|
|
|
|
|
for o in common:
|
|
|
|
|
ra, rb = byo[a][o], byo[b][o]
|
|
|
|
|
ya = ra.actual_forward_return_ATR
|
|
|
|
|
pa = 0.0 if a == "naive" else (ra.normalized_expected_return or np.nan)
|
|
|
|
|
pb = 0.0 if b == "naive" else (rb.normalized_expected_return or np.nan)
|
|
|
|
|
if np.isfinite(pa) and np.isfinite(pb) and ya is not None and np.isfinite(ya):
|
|
|
|
|
mae_diffs.append(abs(ya) - abs(ya - pa) - (abs(ya) - abs(ya - pb)))
|
|
|
|
|
ta = ra.forecast_direction in ("LONG", "SHORT")
|
|
|
|
|
tb = rb.forecast_direction in ("LONG", "SHORT")
|
|
|
|
|
if ta and tb:
|
|
|
|
|
sa = 1.0 if ra.forecast_direction == "LONG" else -1.0
|
|
|
|
|
sb = 1.0 if rb.forecast_direction == "LONG" else -1.0
|
2026-08-24 21:32:58 +07:00
|
|
|
c = atr_map[o] * (rt_bps / 10000.0)
|
|
|
|
|
nets.append((sa * ya - c) - (sb * ya - c))
|
2026-08-24 19:50:21 +07:00
|
|
|
paired[f"{a}__vs__{b}"] = {"n_paired_origins": len(common),
|
|
|
|
|
"net_diff_ci": bootstrap_ci(nets) if nets else None,
|
|
|
|
|
"mae_diff_ci": bootstrap_ci(mae_diffs) if mae_diffs else None}
|
|
|
|
|
all_results[tf] = {"eval": {k: clean(v) for k, v in eval_res.items()}, "paired": paired}
|
|
|
|
|
print(f"[{tf}] " + " | ".join(f"{k}: gross={eval_res[k]['gross_exp']:.3f} "
|
2026-08-24 21:32:58 +07:00
|
|
|
f"net={eval_res[k]['net_exp']:.3f} da={eval_res[k]['dir_acc']:.3f}"
|
2026-08-24 19:50:21 +07:00
|
|
|
for k in ("naive", "arima", "sax", "hybrid")))
|
|
|
|
|
|
|
|
|
|
cost_sens = {}
|
|
|
|
|
for tf, vmap in all_recs.items():
|
|
|
|
|
cost_sens[tf] = {}
|
|
|
|
|
for m in (0.0, 0.5, 1.0, 2.0, 5.0):
|
|
|
|
|
cost_sens[tf][str(m)] = {}
|
|
|
|
|
for name, rr in vmap.items():
|
|
|
|
|
e, _ = eval_study(rr, all_atrmap[tf], rt_bps, mult=m)
|
|
|
|
|
cost_sens[tf][str(m)][name] = e["net_exp"]
|
|
|
|
|
all_results["_cost_sensitivity"] = cost_sens
|
|
|
|
|
|
|
|
|
|
def write(obj, path):
|
|
|
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
|
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
|
|
|
json.dump(obj, fh, indent=2, default=str)
|
|
|
|
|
|
|
|
|
|
write(manifest, os.path.join(OUT_ROOT, "R1_PROTOCOL_MANIFEST.json"))
|
|
|
|
|
for tf in ("M1", "M5", "M15"):
|
|
|
|
|
write(all_results[tf]["eval"], os.path.join(OUT_ROOT, f"R1_{tf.lower()}", f"R1_{tf}_EVAL.json"))
|
|
|
|
|
write(all_results[tf]["paired"], os.path.join(OUT_ROOT, f"R1_{tf.lower()}", f"R1_{tf}_PAIRED.json"))
|
|
|
|
|
write(all_results["_cost_sensitivity"], os.path.join(OUT_ROOT, "R1_COST_SENSITIVITY.json"))
|
|
|
|
|
|
|
|
|
|
flat = []
|
|
|
|
|
for tf in ("M1", "M5", "M15"):
|
|
|
|
|
for name, e in all_results[tf]["eval"].items():
|
|
|
|
|
flat.append({"resolution": tf, "model": name, **e})
|
|
|
|
|
pd.DataFrame(flat).to_csv(os.path.join(OUT_ROOT, "R1_RESULTS.csv"), index=False)
|
|
|
|
|
|
|
|
|
|
print("\n=== R1 SUMMARY ===")
|
|
|
|
|
for tf in ("M1", "M5", "M15"):
|
|
|
|
|
print(f"-- {tf} --")
|
|
|
|
|
for name, e in all_results[tf]["eval"].items():
|
2026-08-24 21:32:58 +07:00
|
|
|
fmt = lambda v: "nan" if v is None else f"{v:.4f}"
|
|
|
|
|
print(f" {name:8s} n={e['n_total']:6d} mae={fmt(e['mae'])} da={fmt(e['dir_acc'])} "
|
|
|
|
|
f"gross={fmt(e['gross_exp'])} net={fmt(e['net_exp'])} pf={fmt(e['profit_factor'])}")
|
2026-08-24 19:50:21 +07:00
|
|
|
print("cost rt_bps:", round(rt_bps, 3))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|