177 lines
5.4 KiB
Python
177 lines
5.4 KiB
Python
"""
|
||
Price History — fetch + cache des cours historiques via yfinance.
|
||
Cache SQLite dans la table price_history_cache.
|
||
"""
|
||
import json
|
||
import sqlite3
|
||
from datetime import datetime, timedelta, date as date_type
|
||
from typing import Optional
|
||
|
||
|
||
YF_TICKERS: dict[str, str] = {
|
||
"EURUSD": "EURUSD=X",
|
||
"USDJPY": "USDJPY=X",
|
||
"XAUUSD": "GC=F",
|
||
"SP500": "^GSPC",
|
||
"TLT": "TLT",
|
||
"GBPUSD": "GBPUSD=X",
|
||
"EEM": "EEM",
|
||
"QQQ": "QQQ",
|
||
}
|
||
|
||
PRICE_LABELS: dict[str, str] = {
|
||
"EURUSD": "EUR/USD",
|
||
"USDJPY": "USD/JPY",
|
||
"XAUUSD": "Or ($/oz)",
|
||
"SP500": "S&P 500",
|
||
"TLT": "TLT ETF",
|
||
"GBPUSD": "GBP/USD",
|
||
"EEM": "EEM ETF",
|
||
"QQQ": "QQQ ETF",
|
||
}
|
||
|
||
PERIOD_DAYS: dict[str, int] = {
|
||
"5d": 7, "1mo": 35, "3mo": 95, "6mo": 190, "1y": 370, "2y": 740,
|
||
}
|
||
|
||
|
||
def _ensure_cache_table(conn):
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS price_history_cache (
|
||
id INTEGER PRIMARY KEY,
|
||
instrument TEXT NOT NULL,
|
||
date TEXT NOT NULL,
|
||
close REAL NOT NULL,
|
||
fetched_at TEXT DEFAULT (datetime('now')),
|
||
UNIQUE(instrument, date)
|
||
)
|
||
""")
|
||
conn.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_price_cache_inst_date
|
||
ON price_history_cache(instrument, date)
|
||
""")
|
||
conn.commit()
|
||
|
||
|
||
def _fetch_yf(instrument: str, period_days: int) -> list[dict]:
|
||
"""Fetch from yfinance. Returns [{date, close}] sorted ascending."""
|
||
try:
|
||
import yfinance as yf
|
||
ticker = YF_TICKERS.get(instrument.upper())
|
||
if not ticker:
|
||
return []
|
||
# yfinance period string
|
||
if period_days <= 7: p = "5d"
|
||
elif period_days <= 35: p = "1mo"
|
||
elif period_days <= 95: p = "3mo"
|
||
elif period_days <= 190: p = "6mo"
|
||
elif period_days <= 370: p = "1y"
|
||
else: p = "2y"
|
||
|
||
df = yf.download(ticker, period=p, interval="1d", progress=False, auto_adjust=True)
|
||
if df is None or df.empty:
|
||
return []
|
||
|
||
# Handle MultiIndex columns (yfinance 0.2+)
|
||
if hasattr(df.columns, 'levels'):
|
||
df.columns = df.columns.get_level_values(0)
|
||
|
||
close_col = next((c for c in ["Close", "Adj Close", "close"] if c in df.columns), None)
|
||
if not close_col:
|
||
return []
|
||
|
||
result = []
|
||
for idx, row in df.iterrows():
|
||
dt = str(idx)[:10]
|
||
v = float(row[close_col])
|
||
if v and v == v: # not NaN
|
||
result.append({"date": dt, "close": round(v, 6)})
|
||
return result
|
||
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def get_price_history(
|
||
conn,
|
||
instrument: str,
|
||
period: str = "1y",
|
||
force_refresh: bool = False,
|
||
) -> list[dict]:
|
||
"""
|
||
Retourne [{date, close}] pour l'instrument sur la période.
|
||
Cache SQLite — rafraîchit si les données datent de plus de 6h.
|
||
"""
|
||
_ensure_cache_table(conn)
|
||
inst = instrument.upper()
|
||
days = PERIOD_DAYS.get(period, 370)
|
||
date_from = (datetime.utcnow().date() - timedelta(days=days)).isoformat()
|
||
|
||
# Check cache freshness
|
||
cache_ok = False
|
||
if not force_refresh:
|
||
newest = conn.execute(
|
||
"SELECT fetched_at FROM price_history_cache WHERE instrument=? ORDER BY fetched_at DESC LIMIT 1",
|
||
(inst,)
|
||
).fetchone()
|
||
if newest:
|
||
try:
|
||
age_h = (datetime.utcnow() - datetime.fromisoformat(str(newest[0])[:19])).total_seconds() / 3600
|
||
cache_ok = age_h < 6.0
|
||
except Exception:
|
||
pass
|
||
|
||
if not cache_ok:
|
||
rows = _fetch_yf(inst, days + 30)
|
||
if rows:
|
||
conn.executemany(
|
||
"INSERT OR REPLACE INTO price_history_cache (instrument, date, close) VALUES (?,?,?)",
|
||
[(inst, r["date"], r["close"]) for r in rows]
|
||
)
|
||
conn.commit()
|
||
|
||
# Read from cache
|
||
rows_db = conn.execute(
|
||
"SELECT date, close FROM price_history_cache WHERE instrument=? AND date>=? ORDER BY date ASC",
|
||
(inst, date_from)
|
||
).fetchall()
|
||
return [{"date": r[0], "close": r[1]} for r in rows_db]
|
||
|
||
|
||
def calibrate_intercept(
|
||
conn,
|
||
instrument: str,
|
||
model_pips_at_ref: float,
|
||
ref_date: Optional[str] = None,
|
||
) -> Optional[float]:
|
||
"""
|
||
Calcule l'intercept = real_price - model_pips × pip_to_price au point de référence.
|
||
Si ref_date non fourni, utilise il y a 30 jours.
|
||
"""
|
||
from services.instrument_models import INSTRUMENT_MODELS
|
||
meta = INSTRUMENT_MODELS.get(instrument.upper(), {})
|
||
pip_to_price = meta.get("pip_to_price", 0.0001)
|
||
|
||
if ref_date is None:
|
||
ref_date = (datetime.utcnow().date() - timedelta(days=30)).isoformat()
|
||
|
||
# Find nearest price to ref_date
|
||
row = conn.execute(
|
||
"""SELECT date, close FROM price_history_cache
|
||
WHERE instrument=? AND date<=? ORDER BY date DESC LIMIT 1""",
|
||
(instrument.upper(), ref_date)
|
||
).fetchone()
|
||
if not row:
|
||
# Try fetching
|
||
history = get_price_history(conn, instrument, "3mo", force_refresh=True)
|
||
row = conn.execute(
|
||
"SELECT date, close FROM price_history_cache WHERE instrument=? AND date<=? ORDER BY date DESC LIMIT 1",
|
||
(instrument.upper(), ref_date)
|
||
).fetchone()
|
||
if not row:
|
||
return None
|
||
|
||
real_price = float(row[1])
|
||
intercept = real_price - model_pips_at_ref * pip_to_price
|
||
return round(intercept, 6)
|