# mt5_quotes_to_json.py
# - 休場(holiday)以外は常に live 扱い（深夜0:00でも "8:00開始" に戻さない）
# - 8:00JST を跨いだときだけ base(8:00基準)を切り替える
# - direction/change_pct は base(8:00) からの変化率で算出
# - EA対象通貨用に bid/ask/mid は holiday 以外は常に実値を出す

import json
import os
import time
import logging
from logging.handlers import RotatingFileHandler
from datetime import datetime, timedelta, timezone, date
from pathlib import Path

import MetaTrader5 as mt5


# =========================
# 設定
# =========================

TERMINAL_PATH = r"C:\MT5_Second\terminal64.exe"

OUTFILE  = r"C:\fx_http\quotes.json"
BASEFILE = r"C:\fx_http\quotes_base.json"
LOGFILE  = r"C:\fx_http\logs\mt5_quotes_to_json.log"

INTERVAL_SEC = 10
INIT_RETRY_SEC = 10

JST = timezone(timedelta(hours=9))

BASELINE_HOUR = 8
BASELINE_MIN  = 0

# 週末休場（あなたの運用ルール）
WEEKEND_CUTOFF_HOUR_JST = 7
WEEKEND_CUTOFF_MIN_JST  = 0

FLAT_EPS_PCT = 0.0001

BASE_SYMBOLS = [
    "USDJPY-",
    "GBPJPY-",
    "GBPUSD-",
    "EURUSD-",
    "AUDUSD-",
    "USDCAD-",
]

FORCE_HOLIDAYS = {
    (1, 1),
}


# =========================
# ログ
# =========================

def setup_logger() -> logging.Logger:
    Path(LOGFILE).parent.mkdir(parents=True, exist_ok=True)
    logger = logging.getLogger("mt5_quotes_to_json")
    logger.setLevel(logging.INFO)

    handler = RotatingFileHandler(
        LOGFILE,
        maxBytes=500_000,
        backupCount=3,
        encoding="utf-8"
    )
    fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
    handler.setFormatter(fmt)

    if not logger.handlers:
        logger.addHandler(handler)

    return logger


logger = setup_logger()


# =========================
# ユーティリティ
# =========================

def now_jst() -> datetime:
    return datetime.now(timezone.utc).astimezone(JST)

def normalize_pair(sym: str) -> str:
    s = str(sym or "")
    s = s.replace("/", "").replace("_", "").replace("-", "")
    return s.upper()

def read_json(path: str):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None

def atomic_write_json(path: str, data: dict) -> None:
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    payload = json.dumps(data, ensure_ascii=False, indent=2)

    last_err = None
    for _ in range(10):
        try:
            with open(tmp, "w", encoding="utf-8") as f:
                f.write(payload)
            os.replace(tmp, path)
            return
        except Exception as e:
            last_err = e
            time.sleep(0.2)

    try:
        with open(path, "w", encoding="utf-8") as f:
            f.write(payload)
    except Exception as e:
        logger.warning(f"atomic_write_json failed: {repr(last_err)} fallback failed: {repr(e)}")


# =========================
# MT5 接続/シンボル
# =========================

def ensure_mt5_connected() -> bool:
    if mt5.initialize(TERMINAL_PATH):
        return True

    logger.warning(f"mt5.initialize failed: {mt5.last_error()}")
    try:
        mt5.shutdown()
    except Exception:
        pass

    time.sleep(2)

    ok = mt5.initialize(TERMINAL_PATH)
    logger.info(f"mt5.initialize retry: {ok} {mt5.last_error()}")
    if not ok:
        time.sleep(INIT_RETRY_SEC)
    return ok

def build_symbol_map() -> dict:
    mapping = {}
    all_syms = mt5.symbols_get()
    if not all_syms:
        return {s: s for s in BASE_SYMBOLS}

    all_names = [s.name for s in all_syms if getattr(s, "name", None)]

    for base in BASE_SYMBOLS:
        base_norm = normalize_pair(base)
        cand = None

        for name in all_names:
            if normalize_pair(name) == base_norm:
                cand = name
                break

        if cand is None:
            for name in all_names:
                if base_norm in normalize_pair(name):
                    cand = name
                    break

        mapping[base] = cand if cand else base

    return mapping


# =========================
# 休場判定（holiday）
# =========================

def is_force_holiday(d: date) -> bool:
    return (d.month, d.day) in FORCE_HOLIDAYS

def is_market_closed_jst(dt_jst: datetime) -> bool:
    wd = dt_jst.weekday()  # Mon=0 .. Sun=6
    cutoff_min = WEEKEND_CUTOFF_HOUR_JST * 60 + WEEKEND_CUTOFF_MIN_JST
    hm = dt_jst.hour * 60 + dt_jst.minute

    if wd == 5:  # Sat
        return hm >= cutoff_min
    if wd == 6:  # Sun
        return True
    if wd == 0:  # Mon
        return hm < cutoff_min
    return False

def market_mode_jst(now: datetime) -> tuple[str, str]:
    d = now.date()
    if is_force_holiday(d):
        return ("holiday", "休日")
    if is_market_closed_jst(now):
        return ("holiday", "休日")
    # ★ここが重要：preopenを廃止。休場以外は常に live
    return ("live", "")


# =========================
# 8:00基準値（quotes_base.json）
# =========================

def load_base() -> dict:
    d = read_json(BASEFILE)
    if not isinstance(d, dict):
        return {}
    return d

def save_base(base_date_str: str, base_mid: dict) -> None:
    atomic_write_json(BASEFILE, {
        "date_jst": base_date_str,
        "baseline_jst": f"{BASELINE_HOUR:02d}:{BASELINE_MIN:02d}",
        "base_mid": base_mid,
    })

def should_roll_base(now: datetime, base_date_str: str) -> bool:
    today_str = now.strftime("%Y-%m-%d")
    baseline_dt = now.replace(hour=BASELINE_HOUR, minute=BASELINE_MIN, second=0, microsecond=0)
    if now < baseline_dt:
        return False
    return base_date_str != today_str


# =========================
# 方向算出
# =========================

def calc_change_pct(mid: float, base_mid: float):
    if base_mid is None or base_mid == 0 or mid is None:
        return None
    return (mid / base_mid - 1.0) * 100.0

def direction_from_pct(pct: float) -> str:
    if pct is None:
        return "flat"
    if abs(pct) < FLAT_EPS_PCT:
        return "flat"
    return "up" if pct > 0 else "down"


# =========================
# メイン
# =========================

def main():
    logger.info("start mt5_quotes_to_json.py (market_live_until_weekend_v1)")
    logger.info(f"TERMINAL_PATH={TERMINAL_PATH}")
    logger.info(f"OUTFILE={OUTFILE}")
    logger.info(f"BASEFILE={BASEFILE}")
    logger.info(f"INTERVAL_SEC={INTERVAL_SEC}")

    ensure_mt5_connected()

    symbol_map = build_symbol_map()
    logger.info("symbol map: " + ", ".join([f"{k}->{v}" for k, v in symbol_map.items()]))

    base_state = load_base()
    base_date = base_state.get("date_jst")
    base_mid = base_state.get("base_mid") if isinstance(base_state.get("base_mid"), dict) else {}

    while True:
        now = now_jst()
        date_str = now.strftime("%Y-%m-%d")

        if not base_date:
            # BASEFILEが無い初回は「8:00を跨ぐまでは前日基準」としておく
            baseline_dt = now.replace(hour=BASELINE_HOUR, minute=BASELINE_MIN, second=0, microsecond=0)
            if now < baseline_dt:
                base_date = (now - timedelta(days=1)).strftime("%Y-%m-%d")
            else:
                base_date = date_str
            base_mid = {}
            save_base(base_date, base_mid)
            logger.info(f"base init: date_jst={base_date}")

        if should_roll_base(now, base_date):
            base_date = date_str
            base_mid = {}
            save_base(base_date, base_mid)
            logger.info(f"base rolled at baseline: date_jst={base_date}")

        mode, mode_text = market_mode_jst(now)

        if not mt5.initialize(TERMINAL_PATH):
            logger.warning(f"mt5.initialize failed (loop): {mt5.last_error()}")
            ensure_mt5_connected()

        captured_base_now = False
        quotes_out = []

        for base_sym in BASE_SYMBOLS:
            item = {"symbol": base_sym}
            try:
                real_sym = symbol_map.get(base_sym, base_sym)

                if not mt5.symbol_select(real_sym, True):
                    item.update({
                        "status": "holiday" if mode == "holiday" else "waiting",
                        "status_text": "休日" if mode == "holiday" else "シンボル未登録",
                        "direction": "flat",
                        "change_pct": None,
                        "diff_from_open": None,
                        "open_08jst": base_mid.get(base_sym),
                        "open_08jst_time": f"{base_date} 08:00:00" if base_sym in base_mid else None,
                        "mid": None,
                        "bid": None,
                        "ask": None,
                    })
                    quotes_out.append(item)
                    continue

                tick = mt5.symbol_info_tick(real_sym)
                if tick is None:
                    item.update({
                        "status": "holiday" if mode == "holiday" else "waiting",
                        "status_text": "休日" if mode == "holiday" else "開始待ち",
                        "direction": "flat",
                        "change_pct": None,
                        "diff_from_open": None,
                        "open_08jst": base_mid.get(base_sym),
                        "open_08jst_time": f"{base_date} 08:00:00" if base_sym in base_mid else None,
                        "mid": None,
                        "bid": None,
                        "ask": None,
                    })
                    quotes_out.append(item)
                    continue

                bid = float(tick.bid) if tick.bid is not None else None
                ask = float(tick.ask) if tick.ask is not None else None

                mid = None
                if bid is not None and ask is not None and bid > 0 and ask > 0:
                    mid = (bid + ask) / 2.0

                # holiday は数値を出さない（従来通り）
                if mode == "holiday":
                    item.update({
                        "status": "holiday",
                        "status_text": "休日",
                        "direction": "flat",
                        "change_pct": None,
                        "diff_from_open": None,
                        "open_08jst": None,
                        "open_08jst_time": None,
                        "mid": None,
                        "bid": None,
                        "ask": None,
                    })
                    quotes_out.append(item)
                    continue

                # live（常に）
                if base_sym not in base_mid and mid is not None:
                    # 8:00以降に初めて取得できた銘柄だけ基準として固定
                    baseline_dt = now.replace(hour=BASELINE_HOUR, minute=BASELINE_MIN, second=0, microsecond=0)
                    if now >= baseline_dt:
                        base_mid[base_sym] = mid
                        captured_base_now = True

                base_val = base_mid.get(base_sym)
                pct = calc_change_pct(mid, base_val)
                direction = direction_from_pct(pct)

                diff = None
                if mid is not None and base_val is not None:
                    diff = mid - base_val

                item.update({
                    "status": "live",
                    "status_text": "",
                    "bid": bid,
                    "ask": ask,
                    "mid": mid,
                    "change_pct": pct,
                    "direction": direction,
                    "open_08jst": base_val,
                    "open_08jst_time": f"{base_date} 08:00:00" if base_val is not None else None,
                    "diff_from_open": diff,
                })
                quotes_out.append(item)

            except Exception as e:
                item.update({
                    "status": "error",
                    "status_text": f"例外:{e}",
                    "direction": "flat",
                    "change_pct": None,
                    "diff_from_open": None,
                    "open_08jst": base_mid.get(base_sym),
                    "open_08jst_time": f"{base_date} 08:00:00" if base_sym in base_mid else None,
                    "mid": None,
                    "bid": None,
                    "ask": None,
                })
                quotes_out.append(item)

        if captured_base_now:
            save_base(base_date, base_mid)
            logger.info(f"base captured/updated: date={base_date} keys={len(base_mid)}")

        data = {
            "build": "market_live_until_weekend_v1",
            "updated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
            "updated_at_unix": int(time.time()),
            "date_jst": date_str,
            "session_day_jst": base_date,
            "baseline_jst": f"{BASELINE_HOUR:02d}:{BASELINE_MIN:02d}",
            "market_mode": mode,
            "market_mode_text": mode_text,
            "market_status": mode,
            "market_status_text": ("休日" if mode == "holiday" else ""),
            "holiday_rule_jst": f"Sat {WEEKEND_CUTOFF_HOUR_JST:02d}:{WEEKEND_CUTOFF_MIN_JST:02d} to Mon {WEEKEND_CUTOFF_HOUR_JST:02d}:{WEEKEND_CUTOFF_MIN_JST:02d}",
            "quotes": quotes_out,
        }

        atomic_write_json(OUTFILE, data)
        logger.info(f"saved: {OUTFILE} mode={mode} session_day={base_date} symbols={len(quotes_out)}")

        time.sleep(INTERVAL_SEC)


if __name__ == "__main__":
    main()
