forked from chiki2bum2/SniperGold_ML
230 lines
9 KiB
Python
230 lines
9 KiB
Python
# -*- 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_<TF>.csv (arsip teks, audit)
| |||
MQL5\\Files\\AlgoForge\\Data\\XAUUSD_<TF>.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()
|