# -*- coding: utf-8 -*- """ Algo Forge - Tahap 4 (ML Hibrida): download_bars.py ==================================================== Unduh data OHLCV HISTORIS PANJANG (±20 tahun) dari terminal MetaTrader 5 (HFM Demo, simbol XAUUSD) secara chunked per tahun, lalu VERIFIKASI kontinuitas (gap detection + sanity OHLC) dan simpan ke: MQL5\\Files\\AlgoForge\\Data\\XAUUSD_.csv (arsip teks, audit) MQL5\\Files\\AlgoForge\\Data\\XAUUSD_.npz (arrays numpy, cepat) TIDAK menyentuh Files\\SniperGold_ML (cache baseline Fase 3 = deploy freeze). Cara pakai: python download_bars.py [--symbol XAUUSD] [--tf M15,H1,H4,D1] [--force] [--probe] --probe : hanya lapor ketersediaan (bar count, rentang tanggal) tanpa unduh. --force : unduh ulang walau file sudah ada. --start : ISO date awal (default: 2000-01-01 -> terminal kembalikan yang ada). Verifikasi kontinuitas: - urutan waktu strict naik, tanpa duplikat - step antar bar dibandingkan dengan step TF; gap <= 3x step dianggap normal - gap 3x..4 hari = weekend/holiday (dicatat, bukan error) - gap > 4 hari = ANOMALI (dilaporkan; total & terpanjang) - sanity OHLC: high >= max(o,c), low <= min(o,c), volume >= 0 """ import os import sys import json import argparse import datetime as dt import numpy as np import MetaTrader5 as mt5 # ---------------------------------------------------------------------- BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "Files", "AlgoForge", "Data") BASE = os.path.normpath(BASE) TF_MAP = { "M15": mt5.TIMEFRAME_M15, "H1": mt5.TIMEFRAME_H1, "H4": mt5.TIMEFRAME_H4, "D1": mt5.TIMEFRAME_D1, } STEP = {"M15": 15 * 60, "H1": 3600, "H4": 4 * 3600, "D1": 86400} GAP_ANOMALY = 4 * 86400 # > 4 hari = anomali GAP_WEEKEND = 3 * 3600 # batas "normal" (3x step utk M15) DEFAULT_TFS = ["M15", "H1", "H4", "D1"] def ensure_dir(path): os.makedirs(path, exist_ok=True) def fetch_range_chunked(symbol, tf_enum, start, end, step_sec): """Unduh [start, end] per-chunk ~ 2 tahun, gabung, urutkan, dedup.""" chunks = [] cur = start while cur < end: nxt = min(cur + dt.timedelta(days=730), end) rates = mt5.copy_rates_range(symbol, tf_enum, cur, nxt) if rates is None or len(rates) == 0: err = mt5.last_error() print(f" [chunk {cur.date()}..{nxt.date()}] kosong ({err})") else: chunks.append(rates) print(f" [chunk {cur.date()}..{nxt.date()}] {len(rates)} bar") cur = nxt if not chunks: return None allr = np.concatenate(chunks) # urutkan & dedup (jaga-jaga overlap chunk) allr = allr[np.argsort(allr["time"], kind="stable")] _, idx = np.unique(allr["time"], return_index=True) allr = allr[idx] return allr def verify_continuity(rates, tf_name, out_report): """Cek urutan, duplikat, gap, sanity OHLC. Kembali dict laporan.""" t = rates["time"].astype(np.int64) step = STEP[tf_name] rep = {"tf": tf_name, "n": int(len(rates))} if len(rates) == 0: rep.update(status="EMPTY") return rep dup = int(np.sum(np.diff(t) == 0)) not_inc = int(np.sum(np.diff(t) < 0)) d = np.diff(t) gaps = d[d > step * 3] # lebih dari 3x step normal_gaps = int(np.sum((d > step) & (d <= step * 3))) weekend_gaps = int(np.sum((d > step * 3) & (d <= GAP_ANOMALY))) anomaly_gaps = int(np.sum(d > GAP_ANOMALY)) longest = int(d.max()) if len(d) else 0 # sanity OHLC bad_hl = int(np.sum(rates["high"] < np.maximum(rates["open"], rates["close"]))) bad_low = int(np.sum(rates["low"] > np.minimum(rates["open"], rates["close"]))) bad_vol = int(np.sum(rates["tick_volume"] < 0)) first = dt.datetime.utcfromtimestamp(int(t[0])).strftime("%Y-%m-%d %H:%M") last = dt.datetime.utcfromtimestamp(int(t[-1])).strftime("%Y-%m-%d %H:%M") span_years = (t[-1] - t[0]) / (365.25 * 86400) rep.update( status="OK", first=first, last=last, span_years=round(float(span_years), 2), dup=dup, not_increasing=not_inc, normal_gaps=normal_gaps, weekend_gaps=weekend_gaps, anomaly_gaps=anomaly_gaps, longest_gap_sec=int(longest), bad_hl=bad_hl, bad_low=bad_low, bad_vol=bad_vol, ) if dup or not_inc or anomaly_gaps or bad_hl or bad_low or bad_vol: rep["status"] = "WARN" return rep def save(symbol, tf_name, rates): path_csv = os.path.join(BASE, f"{symbol}_{tf_name}.csv") path_npz = os.path.join(BASE, f"{symbol}_{tf_name}.npz") with open(path_csv, "w", encoding="utf-8") as f: f.write("time,open,high,low,close,tick_volume,spread\n") for r in rates: tt = dt.datetime.utcfromtimestamp(int(r["time"])).strftime("%Y-%m-%d %H:%M:%S") f.write(f"{tt},{r['open']:.5f},{r['high']:.5f},{r['low']:.5f}," f"{r['close']:.5f},{int(r['tick_volume'])},{int(r['spread'])}\n") np.savez_compressed(path_npz, time=rates["time"].astype(np.int64), open=rates["open"].astype(np.float64), high=rates["high"].astype(np.float64), low=rates["low"].astype(np.float64), close=rates["close"].astype(np.float64), tick_volume=rates["tick_volume"].astype(np.int64), spread=rates["spread"].astype(np.int64)) print(f" -> {path_csv} ({len(rates)} bar)") print(f" -> {path_npz}") return path_csv, path_npz def main(): ap = argparse.ArgumentParser() ap.add_argument("--symbol", default="XAUUSD") ap.add_argument("--tf", default=",".join(DEFAULT_TFS)) ap.add_argument("--start", default="2000-01-01") ap.add_argument("--force", action="store_true") ap.add_argument("--probe", action="store_true") args = ap.parse_args() tfs = [x.strip().upper() for x in args.tf.split(",") if x.strip()] for x in tfs: if x not in TF_MAP: print(f"TF tidak dikenal: {x} (pilih {list(TF_MAP)})") sys.exit(2) if not mt5.initialize(): print("Gagal initialize():", mt5.last_error()) sys.exit(1) info = mt5.terminal_info() si = mt5.symbol_info(args.symbol) if info is None or si is None: print(f"Terminal/simbol {args.symbol} tidak ditemukan:", mt5.last_error()) mt5.shutdown() sys.exit(1) print(f"Terminal: {info.name} | {info.path}") print(f"Simbol : {args.symbol} | {si.description} | digits={si.digits}") ensure_dir(BASE) start = dt.datetime.strptime(args.start, "%Y-%m-%d") end = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) + dt.timedelta(days=2) all_reports = [] for tf_name in tfs: tf_enum = TF_MAP[tf_name] print(f"\n=== {args.symbol} {tf_name} ===") # probe: bar pertama & jumlah total r0 = mt5.copy_rates_from_pos(args.symbol, tf_enum, 0, 1) bars_total = mt5.symbol_info(args.symbol).history_bars(tf_enum, start, end) \ if hasattr(mt5.symbol_info(args.symbol), "history_bars") else -1 try: r_first = mt5.copy_rates_range(args.symbol, tf_enum, start, end) except Exception as e: # noqa r_first = None print(f" [probe gagal] {e}") if r0 is not None and len(r0): print(f" bar terbaru : {dt.datetime.utcfromtimestamp(int(r0[0]['time']))}") if r_first is not None and len(r_first): print(f" probe range : {len(r_first)} bar | " f"mulai {dt.datetime.utcfromtimestamp(int(r_first[0]['time']))} | " f"akhir {dt.datetime.utcfromtimestamp(int(r_first[-1]['time']))}") full = r_first rep = verify_continuity(full, tf_name, None) all_reports.append(rep) print(f" [probe] n={rep['n']} span={rep['span_years']}y " f"anomali_gap={rep['anomaly_gaps']} status={rep['status']}") if args.probe: continue out_csv, out_npz = save(args.symbol, tf_name, full) rep.update(csv=os.path.basename(out_csv), npz=os.path.basename(out_npz)) else: print(f" [probe] tidak ada data di range (mulai {args.start})") mt5.shutdown() # laporan ringkas print("\n=== RINGKASAN KONTINUITAS ===") for rep in all_reports: print(f" {rep['tf']:4s} n={rep['n']:7d} span={rep['span_years']:6.2f}y " f"{rep.get('first')} .. {rep.get('last')} | dup={rep.get('dup',0)} " f"anom={rep.get('anomaly_gaps',0)} longest={rep.get('longest_gap_sec',0)}s " f"status={rep.get('status')}") if all_reports and not args.probe: path_rep = os.path.join(BASE, "download_report.json") with open(path_rep, "w", encoding="utf-8") as f: json.dump({"generated": dt.datetime.utcnow().isoformat(), "symbol": args.symbol, "reports": all_reports}, f, indent=2) print(f"\nLaporan: {path_rep}") print("Selesai.") if __name__ == "__main__": main()