# -*- coding: utf-8 -*-
"""트레이딩뷰 커뮤니티 '신상' 스크립트 개념 9종 × 448조합 × 두 거래소 검증
— 영상 재현 코드 (AlgoLab)

- 기본 실행: 잣대를 통과한 조합 + 인기 상위 개념의 성적 + 그냥 보유를 재현합니다 (몇 분).
- 전체 재현: `python tv_new_backtest.py --full` — 9개념 448조합 × 업비트·바이낸스
  = 896번 백테스트 전부 (수십 분 걸립니다).

여기 있는 것은 **특정 스크립트의 이식이 아니라 개념의 일반형을 자체 구현한 것**입니다.
트레이딩뷰 /scripts 피드에서 그날 인기 상위에 있던 아이디어를 읽고, 규칙을 우리가 다시
썼습니다. 남의 코드를 옮기지 않았고, 개별 스크립트를 저격하지도 않습니다.

데이터: 업비트 원화(KRW-BTC/ETH)·바이낸스 달러(BTCUSDT/ETHUSDT) 4시간봉, 공개 API.
처음 실행하면 내려받아 CSV 로 저장하고, 다음부터는 재사용합니다.

⚠ ASOF: 영상 제작 시점까지로 데이터를 잘라야 영상과 같은 숫자가 나옵니다.
   오늘까지 보려면 ASOF = None 으로 바꾸세요(숫자는 영상과 달라집니다).
   지난 편(인기 지표 888조합)과 **같은 기준일·같은 잣대**를 씁니다. 그래야 두 편의 수치를
   나란히 비교할 수 있습니다.
⚠ 이 코드는 시뮬레이션입니다. 4시간봉 종가 체결·편도 0.1% 비용을 가정했고 슬리피지는
   반영하지 않았습니다. 과거 성적은 미래 수익을 보장하지 않습니다.
"""
import csv, datetime, json, os, sys, time, urllib.parse, urllib.request
import numpy as np

ASOF = "2026-08-31T12:00:00"  # 영상 제작 기준 마지막 봉(UTC). None 이면 오늘까지.
COST = 0.001                  # 편도 0.1%
WARM = 400                    # 워밍업 봉 수(지표가 자리 잡기 전 구간은 성적에서 뺀다)
VAL_START = "2024-01-01"      # 검증 구간 시작
HERE = os.path.dirname(os.path.abspath(__file__))
BARS_PER_DAY = 6              # 4시간봉


# ── 데이터 수집 (공개 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(c, n):
    out = np.empty_like(c, dtype=float)
    k = 2 / (n + 1)
    out[0] = c[0]
    for i in range(1, len(c)):
        out[i] = c[i] * k + out[i-1] * (1 - k)
    return out


def _atr(h, l, c, n=14):
    pc = np.concatenate(([c[0]], c[:-1]))
    tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
    return _ema(tr, n)


# ══ 개념 9종 — 전부 '그 봉 종가까지의 정보'만 씁니다(다음 봉 반영은 러너가 처리) ══

def sweep_reversal(o, h, l, c, n=20, exit_n=20):
    """유동성 스윕 반전 — 직전 n봉 최저를 아래로 찔렀다가 종가가 그 위로 복귀하면 매수.
    청산: 종가 < EMA(exit_n)."""
    N = len(c); ema = _ema(c, exit_n); sig = np.zeros(N); pos = 0.0
    for t in range(n + 1, N):
        prior_low = l[t-n:t].min()
        if pos == 0.0:
            if l[t] < prior_low and c[t] > prior_low:
                pos = 1.0
        elif c[t] < ema[t]:
            pos = 0.0
        sig[t] = pos
    return sig


def fvg_pullback(o, h, l, c, look=30, exit_n=20):
    """상승 FVG(3봉 갭) 생성 후 look봉 안에 갭 구간으로 되돌아오면 매수."""
    N = len(c); ema = _ema(c, exit_n); sig = np.zeros(N); pos = 0.0
    z_top = z_bot = np.nan; z_age = 10**9
    for t in range(2, N):
        if pos == 0.0:
            if l[t] > h[t-2]:
                z_top, z_bot, z_age = l[t], h[t-2], 0
            else:
                z_age += 1
            if z_age <= look and not np.isnan(z_top):
                if l[t] <= z_top and c[t] > z_bot:
                    pos = 1.0; z_age = 10**9
        else:
            if (not np.isnan(z_bot) and c[t] < z_bot) or c[t] < ema[t]:
                pos = 0.0; z_top = z_bot = np.nan
        sig[t] = pos
    return sig


def devchan_rsi(o, h, l, c, n=120, k=2.0, rsi_n=14):
    """회귀 편차 채널 + RSI 트리거 — n봉 선형회귀 ± k×잔차표준편차의 하단에서 매수."""
    N = len(c); sig = np.zeros(N)
    x = np.arange(n, dtype=float); sx, sxx = x.sum(), (x*x).sum()
    denom = n * sxx - sx * sx
    d = np.diff(c, prepend=c[0])
    up = np.where(d > 0, d, 0.0); dn = np.where(d < 0, -d, 0.0)
    rsi = 100 - 100 / (1 + _ema(up, 2*rsi_n-1) / np.maximum(_ema(dn, 2*rsi_n-1), 1e-12))
    pos = 0.0
    for t in range(n, N):
        y = c[t-n+1:t+1]; sy = y.sum(); sxy = (x*y).sum()
        slope = (n*sxy - sx*sy) / denom
        fit = (sy - slope*sx)/n + slope*x
        sd = (y - fit).std(); mid = fit[-1]
        if pos == 0.0:
            if c[t] < mid - k*sd and rsi[t] > rsi[t-1]:
                pos = 1.0
        elif c[t] >= mid:
            pos = 0.0
        sig[t] = pos
    return sig


def squeeze_break(o, h, l, c, q=0.25, brk_n=20, exit_n=20, bb_n=20, win=252):
    """압축 돌파 — 밴드폭(20봉 표준편차÷평균)이 최근 win봉 하위 q 분위면 '압축'.
    압축 직후 5봉 안에 직전 brk_n봉 고가를 종가로 넘으면 매수. 청산: 종가 < EMA(exit_n)."""
    N = len(c); ema = _ema(c, exit_n)
    width = np.full(N, np.nan)
    for t in range(bb_n, N):
        seg = c[t-bb_n+1:t+1]
        width[t] = seg.std() / max(seg.mean(), 1e-12)
    sig = np.zeros(N); pos = 0.0; armed = -10**9
    for t in range(bb_n + win, N):
        if width[t] <= np.nanquantile(width[t-win+1:t+1], q):
            armed = t
        if pos == 0.0:
            if t - armed <= 5 and c[t] > h[t-brk_n:t].max():
                pos = 1.0
        elif c[t] < ema[t]:
            pos = 0.0
        sig[t] = pos
    return sig


def bos_trend(o, h, l, c, piv=5):
    """구조 전환(BOS) — 확정 스윙 고점 위로 마감하면 매수, 확정 스윙 저점 아래면 청산."""
    N = len(c); sig = np.zeros(N); last_hi = last_lo = np.nan; pos = 0.0
    for t in range(2*piv, N):
        i = t - piv
        if h[i] == h[i-piv:i+piv+1].max():
            last_hi = h[i]
        if l[i] == l[i-piv:i+piv+1].min():
            last_lo = l[i]
        if pos == 0.0:
            if not np.isnan(last_hi) and c[t] > last_hi:
                pos = 1.0
        elif not np.isnan(last_lo) and c[t] < last_lo:
            pos = 0.0
        sig[t] = pos
    return sig


def pivot_channel(o, h, l, c, piv=5, atr_k=0.0):
    """피벗 채널 추세선 돌파 — 확정 스윙 고점 두 개를 이어 연장한 선 위로 마감하면 매수,
    저점 쪽 연장선 아래로 마감하면 청산. atr_k>0 이면 ATR 필터로 잔챙이 구조를 거른다."""
    N = len(c); atr = _atr(h, l, c, 14); sig = np.zeros(N); pos = 0.0
    hi1 = hi2 = lo1 = lo2 = None
    for t in range(2*piv, N):
        i = t - piv
        if h[i] == h[i-piv:i+piv+1].max():
            if hi2 is None or i != hi2[0]:
                hi1, hi2 = hi2, (i, h[i])
        if l[i] == l[i-piv:i+piv+1].min():
            if lo2 is None or i != lo2[0]:
                lo1, lo2 = lo2, (i, l[i])
        up = dn = np.nan
        if hi1 is not None and hi2 is not None and hi2[0] > hi1[0]:
            if atr_k == 0.0 or abs(hi2[1]-hi1[1]) >= atr_k*atr[t]:
                s = (hi2[1]-hi1[1]) / (hi2[0]-hi1[0]); up = hi2[1] + s*(t-hi2[0])
        if lo1 is not None and lo2 is not None and lo2[0] > lo1[0]:
            if atr_k == 0.0 or abs(lo2[1]-lo1[1]) >= atr_k*atr[t]:
                s = (lo2[1]-lo1[1]) / (lo2[0]-lo1[0]); dn = lo2[1] + s*(t-lo2[0])
        if pos == 0.0:
            if not np.isnan(up) and c[t] > up:
                pos = 1.0
        elif not np.isnan(dn) and c[t] < dn:
            pos = 0.0
        sig[t] = pos
    return sig


def reversal_profile(o, h, l, c, look=300, bins=30, piv=5, exit_n=20):
    """반전 밀집 프로파일 — 확정 피벗 가격을 최근 look봉 범위에 bins칸으로 쌓아 가장
    밀집한 가격대를 찾는다. 종가가 그 구역 안이고 직전봉보다 오르면 매수."""
    N = len(c); ema = _ema(c, exit_n); sig = np.zeros(N); pos = 0.0
    pv_i, pv_p = [], []; zlo = zhi = np.nan
    for t in range(2*piv, N):
        i = t - piv
        if h[i] == h[i-piv:i+piv+1].max():
            pv_i.append(i); pv_p.append(h[i])
        if l[i] == l[i-piv:i+piv+1].min():
            pv_i.append(i); pv_p.append(l[i])
        if t % 6 == 0 and pv_i:
            ai = np.asarray(pv_i); ap = np.asarray(pv_p)
            ps = ap[ai >= t - look]
            if len(ps) >= 5:
                lo_, hi_ = ps.min(), ps.max()
                if hi_ > lo_:
                    cnt, edge = np.histogram(ps, bins=bins, range=(lo_, hi_))
                    j = int(cnt.argmax()); zlo, zhi = edge[j], edge[j+1]
        if pos == 0.0:
            if not np.isnan(zlo) and zlo <= c[t] <= zhi and c[t] > c[t-1]:
                pos = 1.0
        elif c[t] < ema[t]:
            pos = 0.0
        sig[t] = pos
    return sig


def momentum_sequence(o, h, l, c, seq=4, exit_seq=2):
    """모멘텀 시퀀스 — 이동평균을 전혀 쓰지 않는다. 종가가 seq봉 연속 오르면 매수,
    exit_seq봉 연속 내리면 청산."""
    N = len(c); sig = np.zeros(N); pos = 0.0; up = dn = 0
    for t in range(1, N):
        if c[t] > c[t-1]:
            up += 1; dn = 0
        elif c[t] < c[t-1]:
            dn += 1; up = 0
        else:
            up = dn = 0
        if pos == 0.0:
            if up >= seq:
                pos = 1.0
        elif dn >= exit_seq:
            pos = 0.0
        sig[t] = pos
    return sig


def spma_trend(o, h, l, c, n=50, pct_=0.8, win=252):
    """충격 백분위 이동평균 — 보통 이동평균은 매 봉 따라가지만, 이 선은 변동폭이 최근
    win봉 상위 구간에 드는 '충격' 봉에서만 가격 쪽으로 움직인다. 종가가 선 위면 매수."""
    N = len(c)
    prev = np.concatenate(([c[0]], c[:-1]))
    r = np.abs(np.diff(c, prepend=c[0]) / np.maximum(np.abs(prev), 1e-12))
    line = np.empty(N); line[0] = c[0]
    k = 2 / (n + 1); sig = np.zeros(N)
    for t in range(1, N):
        lo = max(0, t - win + 1)
        thr = np.quantile(r[lo:t+1], pct_)
        line[t] = line[t-1] + k*(c[t]-line[t-1]) if r[t] >= thr else line[t-1]
        if t < win:
            continue
        sig[t] = 1.0 if c[t] > line[t] else 0.0
    return sig


POOL = {
    "유동성 스윕 반전":  (sweep_reversal,
        [{"n": n, "exit_n": e} for n in (10, 20, 40) for e in (10, 20, 50)]),
    "FVG 되돌림":     (fvg_pullback,
        [{"look": lk, "exit_n": e} for lk in (20, 40) for e in (10, 20, 50)]),
    "편차채널 RSI":    (devchan_rsi,
        [{"n": n, "k": k} for n in (80, 120, 200) for k in (1.5, 2.0, 2.5)]),
    "압축 돌파":       (squeeze_break,
        [{"q": q, "brk_n": b, "exit_n": e} for q in (0.2, 0.3) for b in (20, 40) for e in (20, 50)]),
    "구조 전환 BOS":   (bos_trend, [{"piv": p} for p in (3, 5, 8, 12)]),
    "피벗 채널 돌파":   (pivot_channel,
        [{"piv": p, "atr_k": a} for p in (5, 10) for a in (0.0, 1.0)]),
    "반전 밀집 구역":   (reversal_profile,
        [{"look": lk, "bins": b} for lk in (200, 400) for b in (20, 40)]),
    "모멘텀 시퀀스":    (momentum_sequence,
        [{"seq": s, "exit_seq": e} for s in (3, 4, 5) for e in (2, 3)]),
    "충격 백분위 MA":  (spma_trend,
        [{"n": n, "pct_": p} for n in (20, 50, 100) for p in (0.7, 0.85)]),
}
FILTS = (0, 50, 100, 200)   # 일봉 추세필터: 없음 / SMA50 / 100 / 200 (4시간봉 × 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 * BARS_PER_DAY)
        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]]
    return (1 + pos * S["ret"]) * (1 - cost * np.abs(np.diff(np.r_[0.0, pos])))


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):
    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}%"


# 잣대를 통과한 조합 — 영상에서 공개한 그대로. **10개가 전부 같은 개념입니다.**
# (개념, 함수, 자산, 파라미터, 일봉필터)
SURVIVORS = [
    ("압축 돌파", squeeze_break, "ETH", {"q": 0.3, "brk_n": 40, "exit_n": 20}, 0),
    ("압축 돌파", squeeze_break, "BTC", {"q": 0.3, "brk_n": 20, "exit_n": 50}, 50),
    ("압축 돌파", squeeze_break, "ETH", {"q": 0.3, "brk_n": 40, "exit_n": 20}, 50),
    ("압축 돌파", squeeze_break, "BTC", {"q": 0.3, "brk_n": 20, "exit_n": 20}, 50),
    ("압축 돌파", squeeze_break, "BTC", {"q": 0.3, "brk_n": 20, "exit_n": 50}, 200),
    ("압축 돌파", squeeze_break, "BTC", {"q": 0.2, "brk_n": 20, "exit_n": 50}, 100),
    ("압축 돌파", squeeze_break, "ETH", {"q": 0.2, "brk_n": 40, "exit_n": 20}, 0),
    ("압축 돌파", squeeze_break, "ETH", {"q": 0.3, "brk_n": 40, "exit_n": 20}, 100),
    ("압축 돌파", squeeze_break, "BTC", {"q": 0.2, "brk_n": 20, "exit_n": 20}, 50),
    ("압축 돌파", squeeze_break, "BTC", {"q": 0.2, "brk_n": 20, "exit_n": 50}, 200),
]

# 그날 커뮤니티 인기 상위 개념의 '최고 칸' + 최고 배수 탈락자
# (개념, 함수, 자산, 파라미터, 일봉필터, 설명)
POPULAR = [
    ("피벗 채널 돌파", pivot_channel, "ETH", {"piv": 10, "atr_k": 0.0}, 0, "TV 피드 1위 개념(부스트 784 · 2026-09-05)"),
    ("반전 밀집 구역", reversal_profile, "ETH", {"look": 400, "bins": 20}, 0, "TV 피드 2위 개념(부스트 433)"),
    ("모멘텀 시퀀스", momentum_sequence, "BTC", {"seq": 4, "exit_seq": 3}, 50, "TV 피드 4위 개념(부스트 235)"),
    ("유동성 스윕 반전", sweep_reversal, "BTC", {"n": 10, "exit_n": 50}, 50, "TV 피드 1위 개념(부스트 3,278)"),
    ("충격 백분위 MA", spma_trend, "ETH", {"n": 100, "pct_": 0.7}, 0, "TV 피드 7위 개념(부스트 101)"),
    ("FVG 되돌림", fvg_pullback, "ETH", {"look": 20, "exit_n": 20}, 50, "최고 배수 탈락자"),
]


def main():
    full = "--full" in sys.argv
    print(f"기준일 ASOF = {ASOF or '오늘'} · 비용 편도 {COST*100:.1f}% · 4시간봉")
    print(f"잣대: 업비트·바이낸스 두 시장 모두 연복리 30% 이상 · 최대낙폭 -35% 이내\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"))
    A = {
        "BTC": (series(os.path.join(HERE, "upbit_btc_4h.csv")),
                series(os.path.join(HERE, "binance_btc_4h.csv"))),
        "ETH": (series(os.path.join(HERE, "upbit_eth_4h.csv")),
                series(os.path.join(HERE, "binance_eth_4h.csv"))),
    }
    Su = A["BTC"][0]
    print(f"업비트 BTC {Su['d'][0][:10]} ~ {Su['d'][-1][:10]} ({Su['n']:,}봉)\n")

    print("=" * 76)
    print("① 잣대를 통과한 조합 (영상의 생존자)")
    print("=" * 76)
    for name, fn, asset, param, filt in SURVIVORS:
        Au, Ab = A[asset]
        u, b, mn, wm, ok = cell(Au, Ab, fn, param, filt)
        setting = " · ".join(f"{k} {v}" for k, v in param.items())
        print(f"\n{name} {asset} ({setting}) + 일봉SMA{filt}" if filt
              else f"\n{name} {asset} ({setting}) · 일봉필터 없음")
        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" + "=" * 76)
    print("② 인기 상위 개념의 최고 칸 · 그리고 가장 많이 벌고도 떨어진 조합")
    print("=" * 76)
    for name, fn, asset, param, filt, why in POPULAR:
        Au, Ab = A[asset]
        u, b, mn, wm, ok = cell(Au, Ab, fn, param, filt)
        print(f"  {name:<12} {asset} {param} SMA{filt}")
        print(f"    ({why}) min 연복리 {pct(mn)}"
              f" | 업 {u['mult']:.1f}배/{pct(u['mdd'])} | 바 {b['mult']:.1f}배/{pct(b['mdd'])}"
              f" → {'통과' if ok else '탈락'}")

    print("\n" + "=" * 76)
    print("③ 그냥 보유 (같은 구간·업비트)")
    print("=" * 76)
    for asset, (Au, _) in A.items():
        hc, hm, hx = stats(1 + Au["ret"][WARM:], Au["ppy"])
        print(f"  {asset}: 연복리 {pct(hc)} · 최대낙폭 {pct(hm)} · {hx:.1f}배")

    if not full:
        print("\n(전체 448조합 × 2거래소 = 896번 재현: --full 옵션 · 수십 분)")
        return

    print("\n" + "=" * 76)
    print("④ 전체 스윕 — 9개념 448조합 × 업비트·바이낸스")
    print("=" * 76)
    n_all = n_pass = n_ret = 0
    passed = []
    for strat, (fn, params) in POOL.items():
        for asset, (Au, Ab) in A.items():
            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()
