# -*- coding: utf-8 -*-
"""알고랩 — 「비트코인 봇, RSI는 이 자리에」 편 재현 코드 (2026-09-16 공개)

영상과 페이지에 나오는 숫자를 **전부** 이 파일 하나가 냅니다.
시세는 data_rsi/ 에 얼려 두었으니 인터넷 없이 그대로 돌아갑니다.

    python run_rsi.py            (numpy 만 있으면 됩니다)

봇   : 비트코인 4시간봉 · 슈퍼트렌드(10, 3.0) · 일봉 종가가 30일 이동평균 위일 때만
한 줄: 신호가 켜져도 바로 사지 않고, ta.rsi(close, 14) > 78 이 참이 되는 **첫 봉**에 산다
       (트레이딩뷰의 ta.rsi 는 Wilder 평활(RMA)입니다 — EMA 로 짜면 다른 지표가 됩니다)
시장 : 바이낸스 BTCUSDT + 업비트 KRW-BTC, 헤드라인은 항상 **두 시장 중 나쁜 쪽**
정직 : 신호 다음 봉 체결(룩어헤드 없음) · 편도 0.1% · 워밍업 400봉은 성적에서 뺀다
구간 : 2019-09-01 ~ 2026-09-06 (성적 구간 2019-11-06~, 6.83년) · 검증 구간 2024-01-01~

주의 : 이 한 줄은 **수익을 늘리는 장치가 아닙니다.** 10코인 x 2시장 x 전략 4종 80칸에서
       연복리 중앙값은 4.2%p 내려가고 최대낙폭 중앙값이 11.8%p 얕아집니다(얕아진 칸 72/80).
"""
import datetime
import os
import sys

import numpy as np

sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HERE = os.path.dirname(os.path.abspath(__file__))
FEE, WARM, BPD, VAL = 0.001, 400, 6, "2024-01-01"     # 4시간봉 = 하루 6봉
ST_N, ST_MULT, FILT_DAYS = 10, 3.0, 30
RSI_N, RSI_T = 14, 78
SEED = 1000.0                                          # 만원
FILES = {"바이낸스": os.path.join(HERE, "data_rsi", "BTCUSDT_4h.csv"),
         "업비트":   os.path.join(HERE, "data_rsi", "KRW-BTC_4h.csv")}


# ── 지표 수학 ────────────────────────────────────────────────────────
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 rma(x, n):
    """Wilder 평활 = 트레이딩뷰 ta.rma. 첫 값은 SMA(n) 시드."""
    out = np.full(len(x), np.nan)
    if len(x) < n:
        return out
    out[n - 1] = np.mean(x[:n])
    a = 1.0 / n
    for i in range(n, len(x)):
        out[i] = a * x[i] + (1 - a) * out[i - 1]
    out[:n - 1] = out[n - 1]
    return out


def rsi_tv(c, n):
    """트레이딩뷰 ta.rsi(close, n)."""
    d = np.diff(c, prepend=c[0]); d[0] = 0.0
    up = np.where(d > 0, d, 0.0); dn = np.where(d < 0, -d, 0.0)
    au, ad = rma(up, n), rma(dn, n)
    return 100 - 100 / (1 + au / np.where(ad == 0, 1e-9, ad))


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 supertrend(h, l, c, n=ST_N, mult=ST_MULT):
    """슈퍼트렌드 — 위로 서 있으면 1, 아니면 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 apply_wait(sig, cond):
    """신호가 켜져 있는 동안 조건이 **처음** 참이 되는 봉에 진입한다.

    Pine 으로 옮길 때 별도 상태 변수가 필요 없습니다 —
    무포지션 and baseLong and rsi > 78 이면 strategy.entry,
    baseLong 이 꺼지는 봉에 strategy.close 면 같은 숫자가 나옵니다.
    """
    out = np.zeros(len(sig))
    holding = False
    for t in range(len(sig)):
        if sig[t] <= 0:
            holding = False
            continue
        if not holding and cond[t]:
            holding = True
        if holding:
            out[t] = sig[t]
    return out


# ── 계좌 ────────────────────────────────────────────────────────────
def load(path):
    d, o, h, l, c = [], [], [], [], []
    with open(path, encoding="utf-8") as f:
        next(f)
        for line in f:
            p = line.rstrip("\n").split(",")
            if len(p) < 5:
                continue
            d.append(p[0]); o.append(float(p[1])); h.append(float(p[2]))
            l.append(float(p[3])); c.append(float(p[4]))
    d = np.asarray(d)
    o, h, l, c = (np.asarray(x) for x in (o, h, l, c))
    ret = np.zeros(len(c)); 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, ret=ret, n=len(c), ppy=len(c) / yrs,
                val_i=int(np.searchsorted(d, VAL)))


def run(S, sig, fee=FEE):
    """신호는 **다음 봉**에 반영된다(룩어헤드 없음). 비용은 비중이 바뀐 만큼만."""
    pos = np.r_[0.0, sig[:-1]]
    g = (1 + pos * S["ret"]) * (1 - fee * np.abs(np.diff(np.r_[0.0, pos])))
    return pos, g


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


def won(man):
    man = round(man)
    if man >= 10000:
        e, m = divmod(man, 10000)
        return "%d억 %s만원" % (e, format(m, ",")) if m else "%d억원" % e
    return "%s만원" % format(man, ",")


def report(S, sig, label, fee=FEE):
    pos, g = run(S, sig, fee)
    cagr, mdd, mult = span(g[WARM:], S["ppy"])
    vcagr, vmdd, _ = span(g[max(WARM, S["val_i"]):], S["ppy"])
    on = pos > 0
    starts = np.where(on & ~np.r_[False, on[:-1]])[0]
    ends = np.where(~on & np.r_[False, on[:-1]])[0]
    if len(ends) < len(starts):
        ends = np.r_[ends, S["n"] - 1]
    r = np.array([S["c"][min(b, S["n"] - 1)] / S["c"][a] - 1 for a, b in zip(starts, ends)])
    print("  %-26s 연 %6.1f%% · 최대낙폭 %6.1f%% · Calmar %5.2f · 노출 %.2f · "
          "거래 %3d회 · 승률 %4.1f%% · 검증 %5.1f%%/%6.1f%% · %.0f만원 → %s"
          % (label, cagr * 100, mdd * 100, cagr / abs(mdd) if mdd < 0 else float("nan"),
             pos[WARM:].mean(), len(starts),
             (r > 0).mean() * 100 if len(r) else float("nan"),
             vcagr * 100, vmdd * 100, SEED, won(mult * SEED)))
    return cagr, mdd


def daily_filter(S):
    """일봉 종가가 30일 이동평균 위일 때만 1. 4시간봉이므로 30일 = 180봉."""
    m = sma(S["c"], FILT_DAYS * BPD)
    f = np.zeros(S["n"])
    ok = ~np.isnan(m)
    f[ok] = (S["c"][ok] > m[ok]).astype(float)
    return f


def build(S):
    base = supertrend(S["h"], S["l"], S["c"]) * daily_filter(S)
    cond = np.asarray(rsi_tv(S["c"], RSI_N) > RSI_T, bool)
    cond[:60] = False                       # RSI 가 덜 데워진 앞머리는 안 쓴다
    return base, apply_wait(base, cond), cond


def main():
    print(__doc__.strip())
    print("\n" + "=" * 118)
    mins, worsts = {}, {}
    for name, path in FILES.items():
        S = load(path)
        base, line, cond = build(S)
        print("\n[%s] %s ~ %s · %d봉 · 편도 %.1f%%"
              % (name, S["d"][0][:10], S["d"][-1][:10], S["n"], FEE * 100))
        report(S, np.ones(S["n"]), "그냥 보유")
        c0, m0 = report(S, base, "봇 (RSI 없음)")
        c1, m1 = report(S, line, "봇 + ta.rsi(14)>78 첫봉")
        # 같은 평균 노출로 비중만 줄여 늘 들고 있는 계좌 — 반론 기준선
        p0, _ = run(S, base); p1, _ = run(S, line)
        w = p1[WARM:].mean() / p0[WARM:].mean()
        report(S, base * w, "같은노출 고정비중 w=%.2f" % w)
        mins.setdefault("봇", []).append(c0); worsts.setdefault("봇", []).append(m0)
        mins.setdefault("한줄", []).append(c1); worsts.setdefault("한줄", []).append(m1)
        print("     RSI(%d)>%d 인 봉 %.2f%% · 신호가 켜진 봉 중 %.1f%%"
              % (RSI_N, RSI_T, cond[WARM:].mean() * 100, cond[base > 0].mean() * 100))

    print("\n" + "=" * 118)
    print("두 시장 min 연복리 / worst 최대낙폭  (잣대: 연 30% 이상 · 낙폭 -35% 이내)")
    for k in ("봇", "한줄"):
        mc, wm = min(mins[k]), min(worsts[k])
        print("  %-10s %6.1f%% / %6.1f%%   %s"
              % (k, mc * 100, wm * 100,
                 "통과" if (mc >= 0.30 and wm >= -0.35) else "탈락"))

    print("\n비용을 올리면 (두 시장 min 연복리)")
    for fee in (0.001, 0.002, 0.003, 0.005):
        out = []
        for name, path in FILES.items():
            S = load(path)
            base, line, _ = build(S)
            pos, g = run(S, line, fee)
            out.append(span(g[WARM:], S["ppy"])[0])
        print("  편도 %.1f%% — %5.1f%%  %s" % (fee * 100, min(out) * 100,
                                              "통과" if min(out) >= 0.30 else "탈락"))

    print("\n※ 이 페이지의 모든 수치는 과거 데이터 시뮬레이션이며 투자 권유가 아닙니다.")


if __name__ == "__main__":
    main()
