524 lines
19 KiB
Python
524 lines
19 KiB
Python
"""
|
|
AHAD QUANT — Data Downloader (Forex Edition)
|
|
Downloads 1h candle data from multiple sources for Forex pairs.
|
|
|
|
Sources (by priority):
|
|
1. OANDA v20 REST API — if OANDA_API_KEY is set (recommended)
|
|
2. Yahoo Finance — free, no key required (yfinance)
|
|
|
|
Usage:
|
|
python download_data.py
|
|
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import requests
|
|
|
|
# ── Fix encodage Windows ──────────────────────────────────────────────────────
|
|
if hasattr(sys.stdout, 'reconfigure'):
|
|
try:
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
except Exception:
|
|
pass
|
|
|
|
import config
|
|
|
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
DAYS_BACK = 730 # yfinance 1h limit (2 years)
|
|
MAX_RETRIES = 5
|
|
RETRY_DELAY = 3
|
|
|
|
# OANDA candle granularities map
|
|
OANDA_GRANULARITY = {
|
|
"1m": "M1", "5m": "M5", "15m": "M15", "30m": "M30",
|
|
"1h": "H1", "4h": "H4", "1d": "D",
|
|
}
|
|
|
|
# Yahoo Finance Forex suffix and ticker format
|
|
# EUR/USD → "EURUSD=X"
|
|
YAHOO_SUFFIX = "=X"
|
|
|
|
|
|
# ─── OANDA source ─────────────────────────────────────────────────────────────
|
|
|
|
def _oanda_to_instrument(pair: str) -> str:
|
|
"""EURUSD → EUR_USD (OANDA format)."""
|
|
return f"{pair[:3]}_{pair[3:]}"
|
|
|
|
|
|
def get_candles_oanda(pair: str, interval: str = "1h",
|
|
days: int = DAYS_BACK) -> list[dict]:
|
|
"""
|
|
Fetch historical candles from OANDA v20 API.
|
|
Requires OANDA_API_KEY and OANDA_ACCOUNT_ID in environment.
|
|
"""
|
|
api_key = config.OANDA_API_KEY
|
|
practice = config.OANDA_PRACTICE
|
|
|
|
base_url = (
|
|
"https://api-fxpractice.oanda.com"
|
|
if practice else
|
|
"https://api-fxtrade.oanda.com"
|
|
)
|
|
|
|
instrument = _oanda_to_instrument(pair)
|
|
granularity = OANDA_GRANULARITY.get(interval, "H1")
|
|
|
|
# OANDA max per request = 5000 candles
|
|
candles_per_req = 5000
|
|
end_ts = int(time.time())
|
|
start_ts = end_ts - (days * 24 * 3600)
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
all_candles: list[dict] = []
|
|
seen: set = set()
|
|
current_start = start_ts
|
|
|
|
interval_seconds = {"M1": 60, "M5": 300, "M15": 900, "M30": 1800,
|
|
"H1": 3600, "H4": 14400, "D": 86400}.get(granularity, 3600)
|
|
|
|
while current_start < end_ts:
|
|
url = (
|
|
f"{base_url}/v3/instruments/{instrument}/candles"
|
|
f"?granularity={granularity}"
|
|
f"&from={current_start}"
|
|
f"&to={min(current_start + candles_per_req * interval_seconds, end_ts)}"
|
|
f"&price=M" # midpoint (bid+ask)/2
|
|
)
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
try:
|
|
resp = requests.get(url, headers=headers, timeout=30)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
break
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES:
|
|
time.sleep(RETRY_DELAY * attempt)
|
|
else:
|
|
raise e
|
|
|
|
raw = data.get("candles", [])
|
|
if not raw:
|
|
break
|
|
|
|
for c in raw:
|
|
if not c.get("complete", True):
|
|
continue
|
|
t = int(c["time"].split(".")[0]) if "." in c["time"] else int(c["time"][:10])
|
|
if t not in seen:
|
|
seen.add(t)
|
|
mid = c["mid"]
|
|
all_candles.append({
|
|
"t": t * 1000, # milliseconds (matches Binance format)
|
|
"o": float(mid["o"]),
|
|
"h": float(mid["h"]),
|
|
"l": float(mid["l"]),
|
|
"c": float(mid["c"]),
|
|
"v": float(c.get("volume", 0)),
|
|
})
|
|
|
|
last_t = int(raw[-1]["time"][:10])
|
|
if last_t <= current_start:
|
|
break
|
|
current_start = last_t + interval_seconds
|
|
|
|
if len(raw) < candles_per_req:
|
|
break
|
|
|
|
time.sleep(0.1)
|
|
|
|
all_candles.sort(key=lambda x: x["t"])
|
|
return all_candles
|
|
|
|
|
|
# ─── Yahoo Finance source ─────────────────────────────────────────────────────
|
|
|
|
def get_candles_yfinance(pair: str, interval: str = "1h",
|
|
days: int = DAYS_BACK) -> list[dict]:
|
|
"""
|
|
Fetch historical candles from Yahoo Finance (free, no key required).
|
|
1h data available up to 730 days back.
|
|
"""
|
|
try:
|
|
import yfinance as yf
|
|
except ImportError:
|
|
raise ImportError(
|
|
"yfinance is required for free data download.\n"
|
|
"Install with: pip install yfinance"
|
|
)
|
|
|
|
ticker_sym = f"{pair}{YAHOO_SUFFIX}"
|
|
|
|
# yfinance 1h: max period "730d"
|
|
period_map = {"1m": "7d", "5m": "60d", "15m": "60d", "30m": "60d",
|
|
"1h": "730d", "4h": "730d", "1d": "max"}
|
|
period = period_map.get(interval, "730d")
|
|
|
|
# yfinance interval notation
|
|
yf_interval_map = {"1h": "1h", "4h": "1h", "1d": "1d",
|
|
"1m": "1m", "5m": "5m", "15m": "15m"}
|
|
yf_interval = yf_interval_map.get(interval, "1h")
|
|
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
try:
|
|
ticker = yf.Ticker(ticker_sym)
|
|
df = ticker.history(period=period, interval=yf_interval,
|
|
auto_adjust=True, prepost=False)
|
|
break
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES:
|
|
time.sleep(RETRY_DELAY * attempt)
|
|
else:
|
|
raise e
|
|
|
|
if df is None or df.empty:
|
|
raise ValueError(f"No data returned from Yahoo Finance for {pair}")
|
|
|
|
# If 4h was requested but yfinance only gives 1h, resample
|
|
if interval == "4h" and yf_interval == "1h":
|
|
df = df.resample("4h").agg({
|
|
"Open": "first", "High": "max", "Low": "min",
|
|
"Close": "last", "Volume": "sum",
|
|
}).dropna()
|
|
|
|
candles: list[dict] = []
|
|
for ts, row in df.iterrows():
|
|
# Normalize timestamp to milliseconds
|
|
if hasattr(ts, "timestamp"):
|
|
t_ms = int(ts.timestamp() * 1000)
|
|
else:
|
|
t_ms = int(ts) * 1000
|
|
|
|
candles.append({
|
|
"t": t_ms,
|
|
"o": float(row["Open"]),
|
|
"h": float(row["High"]),
|
|
"l": float(row["Low"]),
|
|
"c": float(row["Close"]),
|
|
"v": float(row.get("Volume", 0)),
|
|
})
|
|
|
|
candles.sort(key=lambda x: x["t"])
|
|
return candles
|
|
|
|
|
|
|
|
# ─── Twelve Data source ───────────────────────────────────────────────────────
|
|
|
|
def get_candles_twelvedata(pair: str, interval: str = "1h",
|
|
days: int = DAYS_BACK) -> list[dict]:
|
|
"""
|
|
Fetch historical Forex candles from Twelve Data API.
|
|
Plan Free : 800 req/jour, données Forex 1h sans clé (rate-limited).
|
|
Inscription gratuite : https://twelvedata.com/
|
|
"""
|
|
api_key = config.TWELVE_DATA_API_KEY
|
|
# Twelve Data interval notation
|
|
_interval_map = {
|
|
"1m": "1min", "5m": "5min", "15m": "15min", "30m": "30min",
|
|
"1h": "1h", "4h": "4h", "1d": "1day",
|
|
}
|
|
td_interval = _interval_map.get(interval, "1h")
|
|
|
|
# Format paire : EURUSD → EUR/USD
|
|
symbol = f"{pair[:3]}/{pair[3:]}"
|
|
|
|
# Nombre de points à récupérer (max 5000 par requête)
|
|
outputsize = min(days * 24, 5000)
|
|
|
|
base_url = "https://api.twelvedata.com/time_series"
|
|
params: dict = {
|
|
"symbol": symbol,
|
|
"interval": td_interval,
|
|
"outputsize": outputsize,
|
|
"format": "JSON",
|
|
"timezone": "UTC",
|
|
}
|
|
if api_key:
|
|
params["apikey"] = api_key
|
|
|
|
from datetime import datetime as _dt # import unique, hors boucle
|
|
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
try:
|
|
resp = requests.get(base_url, params=params, timeout=30)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if data.get("status") == "error" or "values" not in data:
|
|
raise ValueError(f"Twelve Data error: {data.get('message', data)}")
|
|
values = data["values"]
|
|
candles = []
|
|
for bar in reversed(values): # API retourne du plus récent au plus ancien
|
|
dt = _dt.strptime(bar["datetime"], "%Y-%m-%d %H:%M:%S")
|
|
candles.append({
|
|
"t": int(dt.timestamp() * 1000),
|
|
"o": float(bar["open"]),
|
|
"h": float(bar["high"]),
|
|
"l": float(bar["low"]),
|
|
"c": float(bar["close"]),
|
|
"v": float(bar.get("volume", 0)),
|
|
})
|
|
candles.sort(key=lambda x: x["t"])
|
|
return candles
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES:
|
|
time.sleep(RETRY_DELAY * attempt)
|
|
else:
|
|
raise ValueError(f"Twelve Data — {pair}: {e}") from e
|
|
return []
|
|
|
|
|
|
# ─── Alpha Vantage source ─────────────────────────────────────────────────────
|
|
|
|
def get_candles_alphavantage(pair: str, interval: str = "1h",
|
|
days: int = DAYS_BACK) -> list[dict]:
|
|
"""
|
|
Fetch Forex candles from Alpha Vantage.
|
|
Plan Free : 25 req/jour. ALPHA_VANTAGE_API_KEY requis.
|
|
Inscription : https://www.alphavantage.co/support/#api-key
|
|
"""
|
|
api_key = config.ALPHA_VANTAGE_API_KEY
|
|
if not api_key:
|
|
raise ValueError(
|
|
"ALPHA_VANTAGE_API_KEY est requis pour cette source.\n"
|
|
"Inscription gratuite : https://www.alphavantage.co/support/#api-key"
|
|
)
|
|
|
|
from_currency = pair[:3]
|
|
to_currency = pair[3:]
|
|
|
|
# Alpha Vantage FX intervals
|
|
_interval_map = {
|
|
"1m": "1min", "5m": "5min", "15m": "15min", "30m": "30min", "1h": "60min",
|
|
}
|
|
|
|
candles = []
|
|
|
|
if interval == "1d":
|
|
# FX_DAILY endpoint
|
|
url = "https://www.alphavantage.co/query"
|
|
params = {
|
|
"function": "FX_DAILY",
|
|
"from_symbol": from_currency,
|
|
"to_symbol": to_currency,
|
|
"outputsize": "full",
|
|
"apikey": api_key,
|
|
}
|
|
resp = requests.get(url, params=params, timeout=30)
|
|
data = resp.json()
|
|
ts_key = "Time Series FX (Daily)"
|
|
if ts_key not in data:
|
|
raise ValueError(f"Alpha Vantage error: {data}")
|
|
for date_str, bar in sorted(data[ts_key].items()):
|
|
from datetime import datetime
|
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
candles.append({
|
|
"t": int(dt.timestamp() * 1000),
|
|
"o": float(bar["1. open"]),
|
|
"h": float(bar["2. high"]),
|
|
"l": float(bar["3. low"]),
|
|
"c": float(bar["4. close"]),
|
|
"v": 0.0,
|
|
})
|
|
else:
|
|
av_interval = _interval_map.get(interval, "60min")
|
|
url = "https://www.alphavantage.co/query"
|
|
params = {
|
|
"function": "FX_INTRADAY",
|
|
"from_symbol": from_currency,
|
|
"to_symbol": to_currency,
|
|
"interval": av_interval,
|
|
"outputsize": "full",
|
|
"apikey": api_key,
|
|
}
|
|
resp = requests.get(url, params=params, timeout=30)
|
|
data = resp.json()
|
|
ts_key = f"Time Series FX ({av_interval})"
|
|
if ts_key not in data:
|
|
raise ValueError(f"Alpha Vantage error: {data}")
|
|
from datetime import datetime
|
|
for dt_str, bar in sorted(data[ts_key].items()):
|
|
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
|
|
candles.append({
|
|
"t": int(dt.timestamp() * 1000),
|
|
"o": float(bar["1. open"]),
|
|
"h": float(bar["2. high"]),
|
|
"l": float(bar["3. low"]),
|
|
"c": float(bar["4. close"]),
|
|
"v": 0.0,
|
|
})
|
|
|
|
candles.sort(key=lambda x: x["t"])
|
|
return candles
|
|
|
|
|
|
# ─── Unified getter (DATA_SOURCE agnostic) ────────────────────────────────────
|
|
|
|
def get_candles(pair: str, interval: str = "1h",
|
|
days: int = DAYS_BACK) -> list[dict]:
|
|
"""
|
|
Download candle data for a Forex pair.
|
|
|
|
La source est contrôlée par DATA_SOURCE dans .env (indépendant du broker) :
|
|
auto → OANDA si OANDA_API_KEY défini, sinon yfinance
|
|
oanda → OANDA v20 REST API (OANDA_API_KEY requis)
|
|
yfinance → Yahoo Finance (gratuit, 2 ans max)
|
|
twelvedata → Twelve Data (800 req/j gratuit, clé recommandée)
|
|
alphavantage → Alpha Vantage (25 req/j, ALPHA_VANTAGE_API_KEY requis)
|
|
mt5 → MetaTrader 5 Python SDK (Windows uniquement)
|
|
ccxt → via broker CCXT_BROKER
|
|
"""
|
|
source = getattr(config, "DATA_SOURCE", "auto").lower()
|
|
|
|
if source == "oanda":
|
|
return get_candles_oanda(pair, interval, days)
|
|
elif source == "yfinance":
|
|
return get_candles_yfinance(pair, interval, days)
|
|
elif source == "twelvedata":
|
|
return get_candles_twelvedata(pair, interval, days)
|
|
elif source == "alphavantage":
|
|
return get_candles_alphavantage(pair, interval, days)
|
|
elif source in ("mt5", "metatrader5"):
|
|
# Données directement depuis MT5 Python SDK
|
|
try:
|
|
import MetaTrader5 as mt5
|
|
tf_map = {"1m": 1, "5m": 5, "15m": 15, "30m": 30,
|
|
"1h": 16385, "4h": 16388, "1d": 16408}
|
|
tf = tf_map.get(interval, 16385)
|
|
rates = mt5.copy_rates_from_pos(pair, tf, 0, days * 24)
|
|
if rates is None:
|
|
raise ValueError(f"MT5: aucune donnée pour {pair}")
|
|
return [
|
|
{"t": int(r["time"]) * 1000, "o": float(r["open"]),
|
|
"h": float(r["high"]), "l": float(r["low"]),
|
|
"c": float(r["close"]), "v": float(r.get("tick_volume", 0))}
|
|
for r in rates
|
|
]
|
|
except ImportError:
|
|
raise ImportError("MetaTrader5 SDK requis. pip install MetaTrader5 (Windows uniquement)")
|
|
elif source == "ccxt":
|
|
# Données via ccxt broker
|
|
try:
|
|
import ccxt
|
|
broker = getattr(config, "CCXT_BROKER", "")
|
|
if not broker:
|
|
raise ValueError("CCXT_BROKER doit être défini dans .env (ex: 'ig')")
|
|
cls = getattr(ccxt, broker)
|
|
client = cls({"apiKey": config.CCXT_API_KEY, "secret": config.CCXT_API_SECRET,
|
|
"enableRateLimit": True})
|
|
ohlcv = client.fetch_ohlcv(f"{pair[:3]}/{pair[3:]}", interval, limit=min(days * 24, 1000))
|
|
return [{"t": c[0], "o": c[1], "h": c[2], "l": c[3], "c": c[4], "v": c[5]}
|
|
for c in ohlcv]
|
|
except Exception as e:
|
|
raise ValueError(f"CCXT data error: {e}") from e
|
|
else:
|
|
# auto : OANDA si clé disponible, sinon yfinance
|
|
if config.OANDA_API_KEY:
|
|
return get_candles_oanda(pair, interval, days)
|
|
elif getattr(config, "TWELVE_DATA_API_KEY", ""):
|
|
return get_candles_twelvedata(pair, interval, days)
|
|
else:
|
|
return get_candles_yfinance(pair, interval, days)
|
|
|
|
|
|
|
|
# ─── Main download loop ───────────────────────────────────────────────────────
|
|
|
|
def download_all() -> None:
|
|
"""Download candle data for all configured Forex pairs and save to data/."""
|
|
os.makedirs(config.DATA_DIR, exist_ok=True)
|
|
|
|
# Détermination de la source affichée
|
|
_src = getattr(config, "DATA_SOURCE", "auto").lower()
|
|
if _src == "auto":
|
|
if config.OANDA_API_KEY:
|
|
source_label = "OANDA v20 (auto-sélectionné)"
|
|
elif getattr(config, "TWELVE_DATA_API_KEY", ""):
|
|
source_label = "Twelve Data (auto-sélectionné)"
|
|
else:
|
|
source_label = "Yahoo Finance (auto-sélectionné, gratuit)"
|
|
elif _src == "oanda":
|
|
source_label = "OANDA v20"
|
|
elif _src == "yfinance":
|
|
source_label = "Yahoo Finance"
|
|
elif _src == "twelvedata":
|
|
source_label = "Twelve Data"
|
|
elif _src == "alphavantage":
|
|
source_label = "Alpha Vantage"
|
|
elif _src in ("mt5", "metatrader5"):
|
|
source_label = "MetaTrader 5 SDK"
|
|
elif _src == "ccxt":
|
|
source_label = f"CCXT ({getattr(config, 'CCXT_BROKER', '?')})"
|
|
else:
|
|
source_label = _src
|
|
|
|
print(f"Downloading {DAYS_BACK} days of {config.CANDLE_INTERVAL} candles "
|
|
f"for {len(config.PAIRS)} Forex pairs...")
|
|
print(f"Source : {source_label} (DATA_SOURCE={_src})")
|
|
print(f"Broker : {config.EXCHANGE} (pour l'exécution)")
|
|
print(f"Saving : {config.DATA_DIR}/\n")
|
|
|
|
failed: list[str] = []
|
|
|
|
# Délai adaptatif selon la source (éviter le rate-limiting)
|
|
_src = getattr(config, "DATA_SOURCE", "auto").lower()
|
|
if _src in ("yfinance", "auto") and not config.OANDA_API_KEY:
|
|
_sleep = 1.5 # yfinance : 1.5s entre paires (rate limit discret)
|
|
elif _src == "alphavantage":
|
|
_sleep = 15.0 # Alpha Vantage free : 25 req/j → 4 req/min max
|
|
elif _src == "twelvedata":
|
|
_sleep = 8.0 if not getattr(config, "TWELVE_DATA_API_KEY", "") else 0.8
|
|
# Sans clé : ~8 req/min | Avec clé plan free : 800 req/j → ~1 req/s
|
|
elif _src == "oanda":
|
|
_sleep = 0.3 # OANDA : API rapide avec clé
|
|
else:
|
|
_sleep = 1.0 # défaut raisonnable
|
|
|
|
print(f" (délai entre paires : {_sleep}s pour éviter le rate-limiting)\n")
|
|
|
|
for i, pair in enumerate(config.PAIRS, 1):
|
|
try:
|
|
candles = get_candles(pair, config.CANDLE_INTERVAL, DAYS_BACK)
|
|
path = os.path.join(config.DATA_DIR, f"{pair}_1h.json")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(candles, f)
|
|
days_actual = round(len(candles) / 24)
|
|
status = "OK" if days_actual >= 600 else "[données limitées]"
|
|
print(f" [{i:2d}/{len(config.PAIRS)}] {pair:8s} -- "
|
|
f"{len(candles):,} candles (~{days_actual} jours) {status}")
|
|
except Exception as e:
|
|
print(f" [{i:2d}/{len(config.PAIRS)}] {pair:8s} -- ERREUR: {e}")
|
|
failed.append(pair)
|
|
|
|
time.sleep(_sleep) # rate limit adaptatif selon DATA_SOURCE
|
|
|
|
# Retry failed pairs
|
|
if failed:
|
|
print(f"\n[!] {len(failed)} paire(s) échouée(s) : {', '.join(failed)}")
|
|
print("Retry dans 10 secondes...\n")
|
|
time.sleep(10)
|
|
|
|
for pair in failed:
|
|
try:
|
|
candles = get_candles(pair, config.CANDLE_INTERVAL, DAYS_BACK)
|
|
path = os.path.join(config.DATA_DIR, f"{pair}_1h.json")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(candles, f)
|
|
days_actual = round(len(candles) / 24)
|
|
print(f" [OK] {pair:8s} -- {len(candles):,} candles récupérés!")
|
|
except Exception as e:
|
|
print(f" [FAIL] {pair:8s} -- Échec définitif: {e}")
|
|
|
|
print("\n[DONE] Données sauvegardées dans data/")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
download_all()
|