# -*- coding: utf-8 -*-
"""트레이딩뷰 인기 지표 888조합 × 두 거래소 검증 — 영상 재현 코드 (AlgoLab)

영상: 「트레이딩뷰 지표 888개, 1000만원이 6억과 5만원」
- 기본 실행: 생존 4개 조합 + 슈퍼트렌드 착시 사례 + 그냥 보유를 재현합니다 (몇 분).
- 전체 재현: `python tv_indicator_backtest.py --full` — 13종 888조합 × 업비트·바이낸스
  = 1776번 백테스트 전부 (수십 분 걸립니다).

데이터: 업비트 원화(KRW-BTC/ETH)·바이낸스 달러(BTCUSDT/ETHUSDT) 4시간봉, 공개 API.
처음 실행하면 내려받아 CSV 로 저장하고, 다음부터는 재사용합니다.

⚠ ASOF: 영상 제작 시점(2026-08-31)까지로 데이터를 잘라야 영상과 같은 숫자가 나옵니다.
   오늘까지 보려면 ASOF = None 으로 바꾸세요(숫자는 영상과 달라집니다).
⚠ 이 코드는 시뮬레이션입니다. 4시간봉 종가 체결·편도 0.1% 비용을 가정했고 슬리피지는
   반영하지 않았습니다. 과거 성적은 미래 수익을 보장하지 않습니다.
"""
import csv, datetime, json, os, sys, time, urllib.parse, urllib.request
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view as swv

ASOF = "2026-08-31T12:00:00"  # 영상 제작 기준 마지막 봉(UTC). None 이면 오늘까지.
# ⚠ 날짜가 아니라 봉 시각까지 고정한 이유: 같은 날이라도 뒤 봉 2개가 더 들어오면
#   바이낸스 쪽 수치가 0.1%p 단위로 밀린다(실측). 영상과 자릿수까지 같으려면 이 시각 그대로.
# ⚠ 그래도 남는 오차: 영상 데이터는 마지막 봉이 확정되기 전에 수집됐습니다. 그 한 봉 차이로
#   바이낸스 검증 구간 일부 수치가 ±0.3%p 안에서 다를 수 있습니다(예: 20.3% ↔ 20.6%).
#   본편에 나온 배수·낙폭 등 핵심 수치는 전부 자릿수까지 일치함을 확인했습니다(2026-09-02).
COST = 0.001                 # 편도 0.1%
WARM = 400
VAL_START = "2024-01-01"     # 검증 구간 시작
HERE = os.path.dirname(os.path.abspath(__file__))


# ── 데이터 수집 (공개 API · 캐시) ────────────────────────────────────────────
def _get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0",
                                               "Accept": "application/json"})
    return json.loads(urllib.request.urlopen(req, timeout=30).read().decode())


def fetch_upbit(market, out):
    if os.path.exists(out):
        return
    print(f"  업비트 {market} 4시간봉 내려받는 중 …")
    rows, to = [], ""
    while True:
        url = (f"https://api.upbit.com/v1/candles/minutes/240?market={market}&count=200"
               + (f"&to={urllib.parse.quote(to)}" if to else ""))
        try:
            batch = _get(url)
        except Exception as e:
            print(f"   재시도({e})"); time.sleep(2); continue
        if not batch:
            break
        for b in batch:
            rows.append((b["candle_date_time_utc"], b["opening_price"], b["high_price"],
                         b["low_price"], b["trade_price"]))
        to = batch[-1]["candle_date_time_utc"]
        time.sleep(0.12)
    rows.reverse()
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f); w.writerow(["date", "open", "high", "low", "close"]); w.writerows(rows)
    print(f"  ✓ {market} {len(rows):,}봉 → {os.path.basename(out)}")


def fetch_binance(symbol, out):
    if os.path.exists(out):
        return
    print(f"  바이낸스 {symbol} 4시간봉 내려받는 중 …")
    rows, start = [], int(datetime.datetime(2017, 8, 1).timestamp() * 1000)
    while True:
        url = (f"https://api.binance.com/api/v3/klines?symbol={symbol}&interval=4h"
               f"&limit=1000&startTime={start}")
        batch = _get(url)
        if not batch:
            break
        for b in batch:
            t = datetime.datetime.utcfromtimestamp(b[0] / 1000).strftime("%Y-%m-%dT%H:%M:%S")
            rows.append((t, b[1], b[2], b[3], b[4]))
        start = batch[-1][0] + 1
        if len(batch) < 1000:
            break
        time.sleep(0.15)
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f); w.writerow(["date", "open", "high", "low", "close"]); w.writerows(rows)
    print(f"  ✓ {symbol} {len(rows):,}봉 → {os.path.basename(out)}")


def load(path):
    """CSV → 배열. ASOF 자르기는 캐시든 새로 받든 똑같이 여기서 적용한다."""
    d, o, h, l, c = [], [], [], [], []
    for r in csv.DictReader(open(path, encoding="utf-8")):
        if ASOF and r["date"] > ASOF:
            continue
        d.append(r["date"]); o.append(float(r["open"])); h.append(float(r["high"]))
        l.append(float(r["low"])); c.append(float(r["close"]))
    return d, np.array(o), np.array(h), np.array(l), np.array(c)


# ── 기초 유틸 (영상 백테스트와 동일 구현) ───────────────────────────────────
def _sma(x, n):
    out = np.full(len(x), np.nan)
    if len(x) >= n:
        cs = np.cumsum(np.insert(x, 0, 0.0))
        out[n-1:] = (cs[n:] - cs[:-n]) / n
    return out


def _ema(x, n):
    a = 2.0 / (n + 1)
    out = np.empty(len(x)); out[0] = x[0]
    for i in range(1, len(x)):
        out[i] = a * x[i] + (1 - a) * out[i-1]
    return out


def _wma(x, n):
    out = np.full(len(x), np.nan)
    if len(x) >= n:
        w = np.arange(1, n + 1, dtype=float)
        out[n-1:] = swv(x, n) @ w / w.sum()
    return out


def _atr(h, l, c, n):
    pc = np.r_[c[0], c[:-1]]
    tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
    a = np.full(len(tr), np.nan)
    if len(tr) >= n:
        a[n-1] = tr[:n].mean()
        k = 1.0 / n
        for i in range(n, len(tr)):
            a[i] = a[i-1] + k * (tr[i] - a[i-1])
    return a


def _rstd(x, n):
    out = np.full(len(x), np.nan)
    if len(x) >= n:
        out[n-1:] = swv(x, n).std(axis=1)
    return out


def _ffill_signal(buy, sell, n_warm, n):
    s = np.full(n, np.nan)
    s[sell] = 0.0
    s[buy] = 1.0
    s[:n_warm] = 0.0
    idx = np.arange(n)
    has = ~np.isnan(s)
    last = np.maximum.accumulate(np.where(has, idx, 0))
    out = s[last]
    out[np.isnan(out)] = 0.0
    return out


# ── 전략 13종 (트레이딩뷰 인기 + 고전) ──────────────────────────────────────
def absmom(o, h, l, c, look=252):
    s = np.zeros(len(c))
    s[look:] = (c[look:] / c[:-look] > 1).astype(float)
    return s


def ma_cross(o, h, l, c, fast=50, slow=200):
    f, s = _sma(c, fast), _sma(c, slow)
    out = np.zeros(len(c)); m = ~(np.isnan(f) | np.isnan(s))
    out[m] = (f[m] > s[m]).astype(float)
    return out


def donchian(o, h, l, c, n=55):
    hh = np.full(len(c), np.nan); ll = np.full(len(c), np.nan)
    if len(c) > n:
        hh[n:] = swv(h, n).max(axis=1)[:-1]
        ll[n:] = swv(l, n).min(axis=1)[:-1]
    return _ffill_signal(c > hh, c < ll, n + 1, len(c))


def supertrend(o, h, l, c, n=10, mult=3.0):
    a = _atr(h, l, c, n); hl2 = (h + l) / 2
    up = hl2 - mult * a; dn = hl2 + mult * a
    d = np.ones(len(c))
    for i in range(1, len(c)):
        if np.isnan(a[i]):
            continue
        if c[i-1] > up[i-1]:
            up[i] = max(up[i], up[i-1])
        if c[i-1] < dn[i-1]:
            dn[i] = min(dn[i], dn[i-1])
        if c[i] > dn[i-1]:
            d[i] = 1
        elif c[i] < up[i-1]:
            d[i] = -1
        else:
            d[i] = d[i-1]
    return (d > 0).astype(float)


def ut_bot(o, h, l, c, key=2.0, atr_n=10):
    a = _atr(h, l, c, atr_n)
    pos = np.zeros(len(c))
    st = np.nan
    for i in range(len(c)):
        if np.isnan(a[i]):
            continue
        loss = key * a[i]
        if np.isnan(st):
            st = c[i] - loss
        prev = st
        if c[i] > prev and c[i-1] > prev:
            st = max(prev, c[i] - loss)
        elif c[i] < prev and c[i-1] < prev:
            st = min(prev, c[i] + loss)
        else:
            st = c[i] - loss if c[i] > prev else c[i] + loss
        pos[i] = 1.0 if c[i] > st else 0.0
    return pos


def hull_trend(o, h, l, c, n=60):
    half = max(2, n // 2); rt = max(2, int(np.sqrt(n)))
    raw = 2 * _wma(c, half) - _wma(c, n)
    hma = np.full(len(c), np.nan)
    ok = ~np.isnan(raw)
    if ok.sum() >= rt:
        i0 = np.argmax(ok)
        hma[i0+rt-1:] = _wma(raw[i0:], rt)[rt-1:]
    out = np.zeros(len(c))
    m = np.zeros(len(c), dtype=bool)
    m[1:] = ~(np.isnan(hma[1:]) | np.isnan(hma[:-1]))
    out[m] = (hma[1:][m[1:]] > hma[:-1][m[1:]]).astype(float)
    return out


def keltner_break(o, h, l, c, n=20, k=1.5):
    e = _ema(c, n); a = _atr(h, l, c, n)
    return _ffill_signal((c > e + k * a) & ~np.isnan(a), c < e, n + 1, len(c))


def ichimoku_trend(o, h, l, c, conv=9, base=26):
    cv = np.full(len(c), np.nan); bs = np.full(len(c), np.nan)
    if len(c) > base:
        cv[conv-1:] = (swv(h, conv).max(axis=1) + swv(l, conv).min(axis=1)) / 2
        bs[base-1:] = (swv(h, base).max(axis=1) + swv(l, base).min(axis=1)) / 2
    out = np.zeros(len(c))
    m = ~(np.isnan(cv) | np.isnan(bs))
    out[m] = ((c[m] > bs[m]) & (cv[m] > bs[m])).astype(float)
    return out


def macd_trend(o, h, l, c, fast=12, slow=26, sig=9):
    m = _ema(c, fast) - _ema(c, slow)
    return (m > _ema(m, sig)).astype(float)


def rsi_meanrev(o, h, l, c, n=14, lo=30, hi=70):
    d = np.diff(c, prepend=c[0])
    up = np.where(d > 0, d, 0.0); dn = np.where(d < 0, -d, 0.0)
    au = _ema(up, n); ad = _ema(dn, n)
    rsi = 100 - 100 / (1 + au / np.where(ad == 0, 1e-9, ad))
    return _ffill_signal(rsi < lo, rsi > hi, n + 1, len(c))


def bollinger_meanrev(o, h, l, c, n=20, k=2.0):
    ma = _sma(c, n); sd = _rstd(c, n)
    return _ffill_signal((c < ma - k * sd) & ~np.isnan(sd), c > ma, n + 1, len(c))


def wavetrend(o, h, l, c, n1=10, n2=21):
    ap = (h + l + c) / 3
    esa = _ema(ap, n1)
    dd = _ema(np.abs(ap - esa), n1)
    ci = (ap - esa) / np.where(dd == 0, 1e-9, 0.015 * dd)
    wt1 = _ema(ci, n2)
    wt2 = _sma(wt1, 4)
    out = np.zeros(len(c))
    m = ~np.isnan(wt2); out[m] = (wt1[m] > wt2[m]).astype(float)
    out[:n1+n2] = 0.0
    return out


def squeeze_mom(o, h, l, c, n=20):
    hh = np.full(len(c), np.nan); ll = np.full(len(c), np.nan)
    if len(c) >= n:
        hh[n-1:] = swv(h, n).max(axis=1)
        ll[n-1:] = swv(l, n).min(axis=1)
    mid = ((hh + ll) / 2 + _sma(c, n)) / 2
    src = c - mid
    mom = np.full(len(c), np.nan)
    if len(c) >= 2 * n:
        x = np.arange(n, dtype=float)
        xm = x - x.mean()
        den = (xm ** 2).sum()
        win = swv(np.nan_to_num(src), n)
        beta = win @ xm / den
        alpha = win.mean(axis=1)
        mom[n-1:] = alpha + beta * xm[-1]
        mom[:2*n] = np.nan
    out = np.zeros(len(c))
    m = ~np.isnan(mom); out[m] = (mom[m] > 0).astype(float)
    return out


POOL = {
    "절대 모멘텀":    (absmom, [{"look": l} for l in (63, 126, 189, 252)]),
    "이동평균 크로스": (ma_cross, [{"fast": f, "slow": s} for f in (10, 20, 50)
                              for s in (60, 100, 150, 200) if f < s]),
    "돈치안 돌파":    (donchian, [{"n": n} for n in (10, 20, 40, 55, 100)]),
    "슈퍼트렌드":     (supertrend, [{"n": n, "mult": m} for n in (7, 10, 14, 20)
                               for m in (1.5, 2.0, 2.5, 3.0, 3.5, 4.0)]),
    "UT Bot":       (ut_bot, [{"key": k, "atr_n": n} for k in (1.0, 2.0, 3.0)
                              for n in (7, 10, 14, 21)]),
    "헐MA 추세":     (hull_trend, [{"n": n} for n in (20, 40, 60, 100, 150)]),
    "켈트너 돌파":    (keltner_break, [{"n": n, "k": k} for n in (20, 50)
                                 for k in (0.5, 1.0, 1.5, 2.0)]),
    "일목 추세":     (ichimoku_trend, [{"conv": cv, "base": b}
                                    for cv, b in ((9, 26), (7, 22), (12, 33))]),
    "MACD 추세":    (macd_trend, [{"fast": f, "slow": s, "sig": g}
                                for f, s, g in ((8, 21, 5), (12, 26, 9), (16, 32, 9), (20, 50, 10))]),
    "RSI 평균회귀":  (rsi_meanrev, [{"n": n, "lo": lo, "hi": hi} for n in (2, 7, 14)
                                for lo in (20, 30) for hi in (60, 70, 80)]),
    "볼린저 평균회귀": (bollinger_meanrev, [{"n": n, "k": k} for n in (20, 30)
                                     for k in (1.5, 2.0, 2.5)]),
    "WaveTrend":    (wavetrend, [{"n1": a, "n2": b} for a in (8, 10, 13) for b in (15, 21)]),
    "Squeeze 모멘텀": (squeeze_mom, [{"n": n} for n in (14, 20, 30, 40)]),
}
FILTS = (0, 50, 100, 200)    # 일봉 추세필터: 없음 / SMA50 / 100 / 200 (4h봉 × 6 = 하루)


# ── 러너 ────────────────────────────────────────────────────────────────────
def series(path):
    d, o, h, l, c = load(path)
    n = len(c)
    ret = np.zeros(n); ret[1:] = c[1:] / c[:-1] - 1
    yrs = (datetime.date.fromisoformat(d[-1][:10])
           - datetime.date.fromisoformat(d[0][:10])).days / 365.25
    return dict(d=d, o=o, h=h, l=l, c=c, n=n, ret=ret, ppy=n / yrs,
                val_i=int(np.searchsorted(np.array(d), VAL_START)))


def run(S, fn, param, filt, cost=COST):
    sig = fn(S["o"], S["h"], S["l"], S["c"], **param)
    if filt:
        sma = _sma(S["c"], filt * 6)
        f = np.zeros(S["n"]); m = ~np.isnan(sma)
        f[m] = (S["c"][m] > sma[m]).astype(float)
        sig = sig * f
    pos = np.r_[0.0, sig[:-1]]
    g = (1 + pos * S["ret"]) * (1 - cost * np.abs(np.diff(np.r_[0.0, pos])))
    return g


def stats(g, ppy):
    eq = np.cumprod(g)
    yrs = len(g) / ppy
    cagr = eq[-1] ** (1 / yrs) - 1 if eq[-1] > 0 else -1.0
    mdd = float((eq / np.maximum.accumulate(eq) - 1).min())
    return float(cagr), mdd, float(eq[-1])


def cell(Su, Sb, fn, param, filt):
    """한 조합을 업비트+바이낸스에서 돌려 (업, 바, min CAGR, 최악 MDD, 통과) 반환."""
    out = []
    for S in (Su, Sb):
        g = run(S, fn, param, filt)
        cagr, mdd, mult = stats(g[WARM:], S["ppy"])
        vc, vm, _ = stats(g[S["val_i"]:], S["ppy"])
        out.append(dict(cagr=cagr, mdd=mdd, mult=mult, val_cagr=vc, val_mdd=vm))
    mn = min(out[0]["cagr"], out[1]["cagr"])
    wm = min(out[0]["mdd"], out[1]["mdd"])
    return out[0], out[1], mn, wm, (mn >= 0.30 and wm >= -0.35)


def pct(x):
    return f"{x*100:.1f}%"


def main():
    full = "--full" in sys.argv
    print(f"기준일 ASOF = {ASOF or '오늘'} · 비용 편도 {COST*100:.1f}% · 4시간봉\n")
    fetch_upbit("KRW-BTC", os.path.join(HERE, "upbit_btc_4h.csv"))
    fetch_upbit("KRW-ETH", os.path.join(HERE, "upbit_eth_4h.csv"))
    fetch_binance("BTCUSDT", os.path.join(HERE, "binance_btc_4h.csv"))
    fetch_binance("ETHUSDT", os.path.join(HERE, "binance_eth_4h.csv"))
    Su = series(os.path.join(HERE, "upbit_btc_4h.csv"))
    Sb = series(os.path.join(HERE, "binance_btc_4h.csv"))
    Eu = series(os.path.join(HERE, "upbit_eth_4h.csv"))
    Eb = series(os.path.join(HERE, "binance_eth_4h.csv"))
    print(f"업비트 BTC {Su['d'][0][:10]} ~ {Su['d'][-1][:10]} ({Su['n']:,}봉)\n")

    print("=" * 74)
    print("① 잣대 통과 4개 조합 (영상의 생존자)")
    print("   잣대: 두 거래소 모두 연복리 30% 이상 · 최대낙폭 -35% 이내")
    print("=" * 74)
    SURV = [
        ("UT Bot",  ut_bot,        {"key": 2.0, "atr_n": 21}, 50),
        ("UT Bot",  ut_bot,        {"key": 3.0, "atr_n": 7},  200),
        ("슈퍼트렌드", supertrend,    {"n": 7, "mult": 2.5},     200),
        ("켈트너",   keltner_break, {"n": 50, "k": 2.0},       200),
    ]
    for name, fn, param, filt in SURV:
        u, b, mn, wm, ok = cell(Su, Sb, fn, param, filt)
        print(f"\n{name} {param} + 일봉SMA{filt}")
        print(f"  업비트 : 연복리 {pct(u['cagr'])} · 최대낙폭 {pct(u['mdd'])} · {u['mult']:.1f}배"
              f" · 검증(2024~) {pct(u['val_cagr'])}")
        print(f"  바이낸스: 연복리 {pct(b['cagr'])} · 최대낙폭 {pct(b['mdd'])} · {b['mult']:.1f}배"
              f" · 검증(2024~) {pct(b['val_cagr'])}")
        print(f"  → min 연복리 {pct(mn)} · 잣대 {'통과' if ok else '탈락'}")

    print("\n" + "=" * 74)
    print("② 단일 거래소 착시 (영상의 슈퍼트렌드 사례 — 업비트만 보면 통과처럼 보인다)")
    print("=" * 74)
    u, b, mn, wm, ok = cell(Su, Sb, supertrend, {"n": 20, "mult": 3.5}, 100)
    print(f"슈퍼트렌드 n20/mult3.5 + SMA100")
    print(f"  업비트 : {u['mult']:.1f}배 ({pct(u['cagr'])}/{pct(u['mdd'])})")
    print(f"  바이낸스: {b['mult']:.1f}배 ({pct(b['cagr'])}/{pct(b['mdd'])}) ← 같은 조합")

    print("\n" + "=" * 74)
    print("③ 그냥 보유 (업비트 비트코인, 같은 구간)")
    print("=" * 74)
    hc, hm, hx = stats(1 + Su["ret"][WARM:], Su["ppy"])
    print(f"  연복리 {pct(hc)} · 최대낙폭 {pct(hm)} · {hx:.1f}배")

    if not full:
        print("\n(전체 888조합 × 2거래소 = 1776번 재현: --full 옵션 · 수십 분)")
        return

    print("\n" + "=" * 74)
    print("④ 전체 스윕 — 13종 888조합 × 업비트·바이낸스 (BTC와 ETH 각각)")
    print("=" * 74)
    n_all = n_pass = n_ret = 0
    passed = []
    for strat, (fn, params) in POOL.items():
        for asset, (Au, Ab) in (("BTC", (Su, Sb)), ("ETH", (Eu, Eb))):
            for param in params:
                for filt in FILTS:
                    u, b, mn, wm, ok = cell(Au, Ab, fn, param, filt)
                    n_all += 1
                    n_ret += mn >= 0.30
                    if ok:
                        n_pass += 1
                        passed.append((strat, asset, param, filt, mn, u, b))
        print(f"  {strat} 완료")
    print(f"\n총 {n_all}칸 · 수익률 관문(min 30%) 통과 {n_ret}칸 · 두 관문 모두 통과 {n_pass}칸")
    for strat, asset, param, filt, mn, u, b in sorted(passed, key=lambda x: -x[4]):
        print(f"  {strat} {asset} {param} SMA{filt} — min {pct(mn)}"
              f" | 업 {u['mult']:.1f}배/{pct(u['mdd'])} | 바 {b['mult']:.1f}배/{pct(b['mdd'])}")


if __name__ == "__main__":
    main()
