# -*- coding: utf-8 -*-
"""strategy_core — 봉수 자산배분 전략 실증(국내 상장 ETF·일봉) 계산 규칙  (2026-10-16)

근거 논문: 「국내주식·미국주식·국내ETF·가상자산 상관관계에 따른 투자전략 실증분석」 v1.1
가상 운용 지수다. 실제 펀드·기준가가 아니며 투자 권유가 아니다.

자산(한국 상장 ETF + 업비트 비트코인)
  US  TIGER 미국S&P500(360750)   KR  KODEX 200(069500)
  BOND KIWOOM 국고채10년(148070)  USD KODEX 미국달러선물(261240)
  CASH KODEX CD금리액티브(합성)(459580)   BTC 업비트 KRW-BTC
목표 비중(Gemini 제시안): 미국 40 · 국내 20 · 안정 25(국채 12.5 + 달러 12.5) · 비트코인 10 · 현금 5
전략
  P6040  미국 60 · 국채 40 단순보유(비교 기준)
  SIMPLE 5자산 고정비중 · 분기 말 리밸런싱            ← 논문 자료가 지지한 단순형
  GEMINI SIMPLE + 주간 허용오차 밴드 + ATR(14) 2.5배 트레일링 손절(국내·비트코인, 현금 ETF로) ← Gemini 동적형
규칙 판정은 매주 마지막 거래일 종가 기준(Gemini 원문), ATR·RSI 는 일봉 14일 Wilder 방식.
손절 뒤 재진입은 다음 분기 말(Gemini 미정 → 논문과 같은 선택).
"""
import math
import numpy as np

ASSETS = ["US", "KR", "BOND", "USD", "BTC", "CASH"]
W_STAR = {"US": .40, "KR": .20, "BOND": .125, "USD": .125, "BTC": .10, "CASH": .05}
W_6040 = {"US": .60, "BOND": .40}
BAND = {"US": .20, "KR": .20, "BOND": .20, "USD": .20, "BTC": .30}     # 상대 허용오차
COST = {"US": .001, "KR": .001, "BOND": .001, "USD": .001, "BTC": .0025, "CASH": .0005}
STOP_ASSETS = ["KR", "BTC"]; K_ATR = 2.5
NAMES = {"US": "미국주식", "KR": "국내주식", "BOND": "국고채10년", "USD": "달러", "BTC": "비트코인", "CASH": "현금(CD금리)"}


def wilder(x, n=14):
    out = np.full(len(x), np.nan); a = 1.0 / n; m = np.nan
    for i, v in enumerate(x):
        if np.isnan(v): out[i] = m; continue
        m = v if np.isnan(m) else m + a * (v - m); out[i] = m
    return out


def rsi(close, n=14):
    d = np.diff(close, prepend=np.nan)
    up = wilder(np.where(d > 0, d, 0.0)); dn = wilder(np.where(d < 0, -d, 0.0))
    with np.errstate(divide="ignore", invalid="ignore"): r = 100 - 100 / (1 + up / dn)
    r[0] = np.nan; return r


def atr(h, l, c, n=14):
    pc = np.concatenate([[np.nan], c[:-1]])
    tr = np.nanmax(np.vstack([h - l, np.abs(h - pc), np.abs(l - pc)]), axis=0)
    return wilder(tr, n)


def k_dca(x):
    if x is None or np.isnan(x): return 1.0
    return 1.5 if x <= 30 else 1.2 if x <= 45 else 1.0 if x < 60 else 0.7 if x < 70 else 0.5


def week_last(dates):
    """각 날짜가 그 주(월~일)의 마지막 거래일인지. 자료의 마지막 날은 금요일일 때만 주말로 본다(다음 날을 아직 모르므로)."""
    import datetime as dt
    d = [dt.date.fromisoformat(x) for x in dates]
    return [(d[i].weekday() >= 4) if i + 1 == len(d) else d[i + 1].isocalendar()[:2] != d[i].isocalendar()[:2] for i in range(len(d))]


def quarter_last(dates):
    """각 날짜가 그 분기의 마지막 거래일인지. 자료의 마지막 날은 분기 마지막 평일일 때만 분기 말로 본다."""
    import datetime as dt, calendar
    q = [(x[:4], (int(x[5:7]) - 1) // 3) for x in dates]
    out = [q[i + 1] != q[i] for i in range(len(q) - 1)]
    d = dt.date.fromisoformat(dates[-1])
    if d.month % 3 == 0:
        last = dt.date(d.year, d.month, calendar.monthrange(d.year, d.month)[1])
        while last.weekday() > 4: last -= dt.timedelta(days=1)
        out.append(d >= last)
    else: out.append(False)
    return out


def run(dates, px, mode, v0=1000.0):
    """px: {asset: {"c":arr,"h":arr,"l":arr}} 같은 날짜축. 반환: nav, 비중 이력, 사건 기록, 마지막 상태."""
    n = len(dates); C = {a: px[a]["c"] for a in px}
    tgt = W_6040 if mode == "P6040" else W_STAR
    hold = {a: v0 * w for a, w in tgt.items()}
    ATR = {a: atr(px[a]["h"], px[a]["l"], px[a]["c"]) for a in STOP_ASSETS if a in px}
    wk, qt = week_last(dates), quarter_last(dates)
    stopped = {a: False for a in STOP_ASSETS}; pmax = {a: C[a][0] for a in STOP_ASSETS}
    nav = [v0]; events = []; costs = 0.0; trades = 0

    def trade(a, amt):
        nonlocal costs, trades
        if abs(amt) < 1e-9: return
        c = abs(amt) * COST[a]; hold[a] += amt; costs += c; trades += 1
        hold["CASH" if "CASH" in hold else a] -= c

    def rebalance(day):
        V = sum(hold.values())
        for a in hold: trade(a, V * tgt[a] - hold[a])
        events.append((day, "분기 리밸런싱", ""))

    for t in range(1, n):
        for a in hold: hold[a] *= C[a][t] / C[a][t - 1]
        if mode == "P6040": nav.append(sum(hold.values())); continue
        if mode == "GEMINI" and wk[t]:
            for a in STOP_ASSETS:
                if stopped[a]: continue
                pmax[a] = max(pmax[a], C[a][t])
                if not np.isnan(ATR[a][t]) and C[a][t] < pmax[a] - K_ATR * ATR[a][t]:
                    amt = hold[a]; trade(a, -amt); hold["CASH"] += amt; stopped[a] = True
                    events.append((dates[t], "손절", a))
        if qt[t]:
            rebalance(dates[t]); stopped = {a: False for a in STOP_ASSETS}; pmax = {a: C[a][t] for a in STOP_ASSETS}
        elif mode == "GEMINI" and wk[t]:
            V = sum(hold.values())
            for a, th in BAND.items():
                if a in stopped and stopped[a]: continue
                if abs(hold[a] / V - W_STAR[a]) > W_STAR[a] * th:
                    amt = V * W_STAR[a] - hold[a]; trade(a, amt); hold["CASH"] -= amt; events.append((dates[t], "밴드", a))
            if hold["CASH"] < 0: rebalance(dates[t])
        nav.append(sum(hold.values()))
    V = sum(hold.values())
    state = {"weights": {a: round(hold[a] / V, 4) for a in hold}, "stopped": dict(stopped) if mode == "GEMINI" else {},
             "pmax": {a: float(pmax[a]) for a in STOP_ASSETS} if mode == "GEMINI" else {}}
    return np.array(nav), events, state, {"trades": trades, "cost_pct": costs / v0 * 100}


def stats(nav, dates, cash=None):
    v = np.asarray(nav, float); r = v[1:] / v[:-1] - 1
    yrs = max(len(r) / 252, 1e-9)
    cagr = (v[-1] / v[0]) ** (1 / yrs) - 1
    vol = r.std(ddof=1) * math.sqrt(252) if len(r) > 2 else float("nan")
    peak = np.maximum.accumulate(v); dd = v / peak - 1
    rc = 0.0 if cash is None else (cash[-1] / cash[0]) ** (1 / yrs) - 1
    return {"cum_pct": (v[-1] / v[0] - 1) * 100, "cagr_pct": cagr * 100, "vol_pct": vol * 100, "mdd_pct": dd.min() * 100,
            "dd_now_pct": dd[-1] * 100, "sharpe": (cagr - rc) / vol if vol and vol > 0 else float("nan"),
            "d1_pct": (v[-1] / v[-2] - 1) * 100 if len(v) > 1 else 0.0, "last": float(v[-1])}


def rolling_corr(dates, px, pairs, win=52):
    """주간(주 마지막 거래일) 수익률의 52주 이동 상관. 반환 {pair: [(date, corr), ...]}"""
    wk = week_last(dates); idx = [i for i, f in enumerate(wk) if f]
    wd = [dates[i] for i in idx]
    R = {a: np.diff(np.log(px[a]["c"][idx])) for a in px}
    out = {}
    for a, b in pairs:
        s = []
        for j in range(win, len(wd)):
            x, y = R[a][j - win:j], R[b][j - win:j]
            if np.std(x) > 0 and np.std(y) > 0: s.append((wd[j], round(float(np.corrcoef(x, y)[0, 1]), 3)))
        out[f"{a}-{b}"] = s
    return out
