229 lines
7.8 KiB
Python
229 lines
7.8 KiB
Python
# -*- coding: utf-8 -*-
| |||
"""R2-H — HORIZON-BOUNDARY EXPERIMENT: deterministic forecast-origin freeze.
| |||
| |||
PURPOSE
| |||
Freeze a NEW reproducible set of walk-forward forecast-origin lists for the
| |||
R2-H elapsed-prediction-horizon experiment (XAUUSDc M1 base grid). This is a
| |||
DESIGN artifact. It runs NO forecasting model, computes NO performance metric,
| |||
and produces NO prediction/evidence artifact.
| |||
| |||
FROZEN ORIGIN-SELECTION RULE (pre-registered, deterministic, model-independent)
| |||
Base grid: M1 (all horizons use the M1 grid; horizon is defined in ELAPSED
| |||
minutes, not bar count).
| |||
| |||
For horizon H (minutes):
| |||
1) Candidate origins: every M1 bar index o in [WARMUP, n).
| |||
2) Endpoint: t_end = first valid M1 bar timestamp >= timestamp(o) + H.
| |||
If no such bar exists in the dataset the origin is EXCLUDED
| |||
(target not resolvable).
| |||
3) Gap rule: let delta[i] be the elapsed minutes from bar i-1 to bar i.
| |||
If any delta in (o, t_end] exceeds GAP_TOLERANCE_MINUTES the origin is
| |||
EXCLUDED (materially missing bars / session boundaries are never
| |||
converted into ordinary contiguous observations).
| |||
4) Origin spacing: consecutive accepted origins are separated by at least
| |||
SPACING_MULT x H elapsed minutes (no overlapping target windows).
| |||
5) No human-selected origins; no model-dependent selection; no cap.
| |||
| |||
FROZEN PARAMETERS
| |||
WARMUP = 2000 (M1 bars; sufficient for ARIMA fit
| |||
window 300, SAX window 24, drift
| |||
lookback 40, ATR 20)
| |||
SPACING_MULT = 2 (spacing = 2 x H elapsed minutes)
| |||
GAP_TOLERANCE_MINUTES = 5 (any inter-bar delta > 5 min in the
| |||
forward window excludes the origin)
| |||
FIT_WINDOW = 300 (recorded for information only)
| |||
ATR_PERIOD = 20 (recorded for information only)
| |||
| |||
All exclusions are deterministic and recorded in the summary manifest.
| |||
| |||
OUTPUTS (per horizon, LF newlines, UTF-8, no BOM)
| |||
origins_H<HHH>.csv columns:
| |||
origin_index, origin_timestamp, target_end_index, target_end_timestamp,
| |||
horizon_minutes, elapsed_minutes, training_start, training_end
| |||
A per-run params/summary JSON is written alongside (R2_H_ORIGIN_PARAMS.json).
| |||
"""
| |||
from __future__ import annotations
| |||
| |||
import argparse
| |||
import hashlib
| |||
import json
| |||
import os
| |||
import platform
| |||
import sys
| |||
from datetime import datetime, timedelta
| |||
| |||
WARMUP = 2000
| |||
SPACING_MULT = 2
| |||
GAP_TOLERANCE_MINUTES = 5
| |||
FIT_WINDOW = 300
| |||
ATR_PERIOD = 20
| |||
| |||
HORIZONS = {
| |||
"H015": 15,
| |||
"H060": 60,
| |||
"H240": 240,
| |||
}
| |||
| |||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
| |||
RAW = os.path.join(ROOT, "results", "R1_real_data", "XAUUSDc_M1_raw.json")
| |||
DEFAULT_OUT = os.path.join(ROOT, "results", "R2-H")
| |||
| |||
CSV_COLS = [
| |||
"origin_index", "origin_timestamp", "target_end_index",
| |||
"target_end_timestamp", "horizon_minutes", "elapsed_minutes",
| |||
"training_start", "training_end",
| |||
]
| |||
| |||
| |||
def sha256_file(path):
| |||
with open(path, "rb") as fh:
| |||
return hashlib.sha256(fh.read()).hexdigest()
| |||
| |||
| |||
def sha256_bytes(buf):
| |||
return hashlib.sha256(buf).hexdigest()
| |||
| |||
| |||
def load_bars(path):
| |||
with open(path, "r", encoding="utf-8") as fh:
| |||
raw = json.load(fh)
| |||
times = []
| |||
closes = []
| |||
for b in raw:
| |||
times.append(datetime.strptime(b["time"], "%Y.%m.%d %H:%M:%S"))
| |||
closes.append(float(b["close"]))
| |||
n = len(times)
| |||
for i in range(1, n):
| |||
if not (times[i] > times[i - 1]):
| |||
raise ValueError("non-ascending or duplicate M1 timestamp at index %d" % i)
| |||
return times, closes
| |||
| |||
| |||
def build_deltas(times):
| |||
"""delta[i] = elapsed minutes from bar i-1 to bar i; delta[0] = 0."""
| |||
n = len(times)
| |||
deltas = [0.0] * n
| |||
for i in range(1, n):
| |||
deltas[i] = (times[i] - times[i - 1]).total_seconds() / 60.0
| |||
return deltas
| |||
| |||
| |||
def first_index_at_or_after(times, target):
| |||
"""First index j with times[j] >= target (binary search on ascending list)."""
| |||
lo, hi = 0, len(times)
| |||
while lo < hi:
| |||
mid = (lo + hi) // 2
| |||
if times[mid] < target:
| |||
lo = mid + 1
| |||
else:
| |||
hi = mid
| |||
return lo
| |||
| |||
| |||
def build_horizon(times, deltas, H):
| |||
"""Return (accepted_origins, exclusions) for one elapsed horizon."""
| |||
n = len(times)
| |||
usable = [] # list of (origin_index, t_end_index, elapsed_minutes)
| |||
exclusions = {"no_endpoint": 0, "gap": 0}
| |||
for o in range(WARMUP, n):
| |||
t_o = times[o]
| |||
target = t_o + timedelta(minutes=H)
| |||
j = first_index_at_or_after(times, target)
| |||
if j >= n:
| |||
exclusions["no_endpoint"] += 1
| |||
continue
| |||
# gap rule: max delta in (o, t_end] must be <= tolerance
| |||
max_delta = max(deltas[o + 1:j + 1])
| |||
if max_delta > GAP_TOLERANCE_MINUTES:
| |||
exclusions["gap"] += 1
| |||
continue
| |||
elapsed = (times[j] - t_o).total_seconds() / 60.0
| |||
usable.append((o, j, elapsed))
| |||
| |||
# origin spacing in elapsed minutes: accept if >= last_accepted + 2*H
| |||
accepted = []
| |||
last_ts = None
| |||
min_spacing = SPACING_MULT * H
| |||
for (o, j, elapsed) in usable:
| |||
t_o = times[o]
| |||
if last_ts is None or (t_o - last_ts).total_seconds() / 60.0 >= min_spacing:
| |||
accepted.append((o, j, elapsed))
| |||
last_ts = t_o
| |||
return accepted, exclusions
| |||
| |||
| |||
def run(out_dir):
| |||
os.makedirs(out_dir, exist_ok=True)
| |||
times, closes = load_bars(RAW)
| |||
deltas = build_deltas(times)
| |||
n = len(times)
| |||
| |||
summary = {
| |||
"design": "R2-H origin freeze",
| |||
"base_grid": "M1",
| |||
"dataset": "results/R1_real_data/XAUUSDc_M1_raw.json",
| |||
"n_M1_bars": n,
| |||
"warmup_M1": WARMUP,
| |||
"spacing_mult": SPACING_MULT,
| |||
"gap_tolerance_minutes": GAP_TOLERANCE_MINUTES,
| |||
"fit_window": FIT_WINDOW,
| |||
"atr_period": ATR_PERIOD,
| |||
"horizons": {},
| |||
"exclusions": {},
| |||
}
| |||
| |||
for key, H in HORIZONS.items():
| |||
accepted, exclusions = build_horizon(times, deltas, H)
| |||
rows = []
| |||
for (o, j, elapsed) in accepted:
| |||
rows.append({
| |||
"origin_index": o,
| |||
"origin_timestamp": times[o].strftime("%Y.%m.%d %H:%M:%S"),
| |||
"target_end_index": j,
| |||
"target_end_timestamp": times[j].strftime("%Y.%m.%d %H:%M:%S"),
| |||
"horizon_minutes": H,
| |||
"elapsed_minutes": elapsed,
| |||
"training_start": max(0, o - FIT_WINDOW + 1),
| |||
"training_end": o,
| |||
})
| |||
lines = [",".join(CSV_COLS)] + [
| |||
",".join(str(r[c]) for c in CSV_COLS) for r in rows
| |||
]
| |||
csv_text = "\n".join(lines) + "\n"
| |||
csv_path = os.path.join(out_dir, "origins_%s.csv" % key)
| |||
with open(csv_path, "w", encoding="utf-8", newline="") as fh:
| |||
fh.write(csv_text)
| |||
| |||
summary["horizons"][key] = {
| |||
"horizon_minutes": H,
| |||
"n_origins": len(rows),
| |||
"n_usable_candidates": len(accepted) + exclusions["gap"] + exclusions["no_endpoint"],
| |||
"sha256": sha256_bytes(csv_text.encode("utf-8")),
| |||
}
| |||
summary["exclusions"][key] = exclusions
| |||
| |||
summary["python"] = platform.python_version()
| |||
summary["executable"] = sys.executable
| |||
| |||
params_path = os.path.join(out_dir, "R2_H_ORIGIN_PARAMS.json")
| |||
with open(params_path, "w", encoding="utf-8", newline="") as fh:
| |||
json.dump(summary, fh, indent=2, sort_keys=True)
| |||
| |||
print("R2-H origin generation complete.")
| |||
for key in HORIZONS:
| |||
h = summary["horizons"][key]
| |||
print(" %s: origins=%d usable_candidates=%d sha256=%s..." %
| |||
(key, h["n_origins"], h["n_usable_candidates"], h["sha256"][:16]))
| |||
print(" params: %s" % params_path)
| |||
return summary
| |||
| |||
| |||
def main():
| |||
ap = argparse.ArgumentParser()
| |||
ap.add_argument("--out", default=DEFAULT_OUT)
| |||
args = ap.parse_args()
| |||
run(args.out)
| |||
| |||
| |||
if __name__ == "__main__":
| |||
main()
|