"""
동시 보유 종목 수만 바꾸면 — 같은 신호, 최대 보유 3 / 5 / 10 / 20개로 10년 돌린 실측
https://algolab.co.kr/blog/max-positions-slots-backtest-2026

진입 : 종가가 직전 20거래일 고가를 넘으면(20일 돌파) -> 다음 날 시가 매수
청산 : (1) 손절 - 진입가 -5%. 장중 저가가 닿으면 손절가 체결
           (시가부터 손절가 아래로 갭이면 시가 체결)
       (2) 추세 이탈 - 종가가 직전 10거래일 저가를 깨면 다음 날 시가 매도
보유 한도 : 동시에 최대 N종목. 새로 살 때 한 종목에 '그 전날 평가금액 / N' 을 넣는다.
            자리가 없으면 그날 신호는 버린다(다음 날로 미루지 않는다).
우선순위 : 빈자리보다 신호가 많은 날 무엇부터 사나
           mom   = 최근 60거래일 수익률이 높은 순 (기본)
           weak  = 최근 60거래일 수익률이 낮은 순
           brk   = 돌파 폭(종가 / 20일 고가)이 큰 순
           rand  = 무작위 (시드 100개)
비용 : 왕복 0.3% (수수료+세금+슬리피지 뭉뚱그린 가정). 매수·매도에 0.15%씩.
종목 : 기준일 시가총액 상위에서 2015년 이전 상장 100종목.
       지금 살아 있고 지금 큰 종목만 골랐다 = 생존 편향 + 사후 선택 편향.
       절대 수익률은 부풀려져 있다. 보유 수끼리 비교하는 용도로만 볼 것.

ASOF 를 고정한 이유: 기준일 없이 돌리면 실행 시점까지 데이터를 받아
글의 숫자와 다르게 나온다. 최신으로 보려면 ASOF = None (숫자 달라짐).
수정주가(FinanceDataReader 기본값) 기준, 배당 미포함. 과거 결과는 미래를 보장하지 않는다.
"""
import numpy as np, pandas as pd, FinanceDataReader as fdr

ASOF = "2026-11-10"     # 기준일 고정 - None 이면 실행 시점까지
START = "2016-01-04"
COST = 0.003
STOP = 0.05
SLOTS = [1, 3, 5, 10, 20, 40, 100]
SEEDS = 100

CODES = """005930 000660 009150 005380 105560 032830 068270 028260 055550 006400
000270 034730 012450 086790 012330 034020 035420 000810 066570 005490
015760 010130 011200 096770 010120 033780 018260 017670 042700 051910
009540 003670 024110 086280 042660 010140 035720 003550 180640 028050
030200 006800 010950 003490 005830 047810 000150 000720 003230 047050
005940 064350 078930 161390 090430 021240 011070 071050 032640 128940
016360 000500 007660 006260 000100 009830 039490 175330 047040 029780
088980 039030 036570 000250 000990 000880 028300 138930 010060 004020
008930 011790 012750 001440 034220 088350 051900 036460 066970 022100
001040 001450 031980 111770 002380 018880 095340 192820 035250 052690""".split()


def load(code):
    df = fdr.DataReader(code, "2015-01-01", ASOF)[["Open", "High", "Low", "Close"]].astype(float)
    if ASOF:
        df = df[df.index <= ASOF]          # 캐시든 새로 받든 같은 자르기
    return df[(df["Open"] > 0) & (df["Low"] > 0)]   # 거래정지일(시가 0) 제외


def prepare(data):
    """종목별 지표를 계산해 공통 달력(행=날짜, 열=종목) 배열로 맞춘다."""
    cal = sorted(set().union(*[d.index for d in data.values()]))
    cal = pd.DatetimeIndex(cal)
    f = {}
    for k in ("o", "l", "c", "hi20", "lo10", "mom", "ok"):
        f[k] = np.full((len(cal), len(data)), np.nan)
    for j, df in enumerate(data.values()):
        x = df.copy()
        x["hi20"] = x["High"].rolling(20).max().shift(1)
        x["lo10"] = x["Low"].rolling(10).min().shift(1)
        x["mom"] = x["Close"] / x["Close"].shift(60) - 1
        x = x.reindex(cal)
        f["ok"][:, j] = x["Open"].notna().values
        for k, col in (("o", "Open"), ("l", "Low"), ("c", "Close"), ("hi20", "hi20"),
                       ("lo10", "lo10"), ("mom", "mom")):
            f[k][:, j] = x[col].values
    f["cmark"] = pd.DataFrame(f["c"]).ffill().values      # 평가용 종가(정지일은 직전 값)
    return cal, f


def simulate(cal, f, n_slots, rule="mom", seed=0):
    o, l, c, hi20, lo10, mom, cm = (f[k] for k in ("o", "l", "c", "hi20", "lo10", "mom", "cmark"))
    ok = f["ok"] == 1
    rng = np.random.default_rng(seed)
    t0 = int(np.searchsorted(cal.values, np.datetime64(START)))
    cash, pos = 1.0, {}                    # pos[j] = [수량, 진입가, 매도 예약, 신호일의 신호 수]
    buys, equity, trades, held, cashw, breadth = [], [], [], [], [], []
    nsig = 0
    seen = taken = 0
    eq_prev = 1.0
    for t in range(t0, len(cal)):
        for j in [j for j, p in pos.items() if p[2] and ok[t, j]]:      # 예약 매도(시가)
            q, ent, _, ns = pos.pop(j)
            cash += q * o[t, j] * (1 - COST / 2)
            trades.append(o[t, j] / ent - 1 - COST); breadth.append(ns)
        for j in buys:                                                   # 예약 매수(시가)
            if not ok[t, j] or j in pos or len(pos) >= n_slots:
                continue
            amt = min(eq_prev / n_slots, cash)
            if amt <= 0:
                continue
            pos[j] = [amt * (1 - COST / 2) / o[t, j], o[t, j], False, nsig]
            cash -= amt
            taken += 1
        for j in [j for j in pos if ok[t, j]]:                           # 장중 손절
            q, ent, _, ns = pos[j]
            sp = ent * (1 - STOP)
            if l[t, j] <= sp:
                px = o[t, j] if o[t, j] < sp else sp
                cash += q * px * (1 - COST / 2)
                trades.append(px / ent - 1 - COST); breadth.append(ns)
                del pos[j]
        eq = cash + sum(p[0] * cm[t, j] for j, p in pos.items())
        equity.append(eq); held.append(len(pos)); cashw.append(cash / eq)
        eq_prev = eq
        for j, p in pos.items():                                         # 추세 이탈 -> 내일 시가 매도
            if ok[t, j] and c[t, j] < lo10[t, j]:
                p[2] = True
        sig = [j for j in np.where(ok[t] & (c[t] > hi20[t]))[0] if j not in pos]
        seen += len(sig)
        free = n_slots - sum(1 for p in pos.values() if not p[2])
        if rule == "mom":
            sig.sort(key=lambda j: -np.nan_to_num(mom[t, j], nan=-9))
        elif rule == "weak":
            sig.sort(key=lambda j: np.nan_to_num(mom[t, j], nan=9))
        elif rule == "brk":
            sig.sort(key=lambda j: -(c[t, j] / hi20[t, j]))
        else:
            rng.shuffle(sig)
        buys, nsig = sig[:max(free, 0)], len(sig)
    eq = pd.Series(equity, index=cal[t0:])
    yrs = (eq.index[-1] - eq.index[0]).days / 365.25
    yearly = eq.groupby(eq.index.year).last().pct_change()
    yearly.iloc[0] = eq.groupby(eq.index.year).last().iloc[0] - 1
    tr = np.array(trades)
    return dict(cagr=eq.iloc[-1] ** (1 / yrs) - 1, mdd=(eq / eq.cummax() - 1).min(),
                trades=len(tr), win=(tr > 0).mean(), exp=tr.mean(), held=np.mean(held),
                full=np.mean(np.array(held) >= n_slots), cash=np.mean(cashw),
                take=taken / seen, worst=yearly.min(), worst_y=int(yearly.idxmin()),
                vol=eq.pct_change().std() * np.sqrt(252), eq=eq, tr=tr, breadth=np.array(breadth))


if __name__ == "__main__":
    data = {k: load(k) for k in CODES}
    cal, f = prepare(data)
    print(f"ASOF {ASOF}  tickers {len(data)}  {cal[int(np.searchsorted(cal.values, np.datetime64(START)))].date()} ~ {cal[-1].date()}\n")

    print("[1] 보유 한도별 (우선순위 = 60일 수익률 높은 순)")
    print("slots   CAGR     MDD    vol   trades  win%   exp   avg_held  full%  cash%  take%  worst_year")
    base = {}
    for n in SLOTS:
        r = base[n] = simulate(cal, f, n, "mom")
        print(f"{n:>4}  {r['cagr']:6.1%}  {r['mdd']:6.1%}  {r['vol']:5.1%}  {r['trades']:>6}  {r['win']:4.0%}  {r['exp']:5.2%}"
              f"  {r['held']:7.1f}  {r['full']:5.0%}  {r['cash']:5.0%}  {r['take']:5.0%}  {r['worst']:6.1%} ({r['worst_y']})")

    print("\n[2] 우선순위 규칙을 바꾸면 (CAGR / MDD)")
    print("slots      mom            weak           brk")
    for n in (3, 5, 10, 20):
        row = []
        for rule in ("mom", "weak", "brk"):
            r = base[n] if rule == "mom" else simulate(cal, f, n, rule)
            row.append(f"{r['cagr']:6.1%} / {r['mdd']:6.1%}")
        print(f"{n:>4}  " + "  ".join(row))

    print(f"\n[3] 무작위로 고르면 (시드 {SEEDS}개) - CAGR 5% / 중앙 / 95% 분위, MDD 중앙 / 최악")
    for n in (3, 5, 10, 20):
        rs = [simulate(cal, f, n, "rand", s) for s in range(SEEDS)]
        cg = np.array([r["cagr"] for r in rs]); md = np.array([r["mdd"] for r in rs])
        print(f"{n:>4}  CAGR {np.percentile(cg, 5):6.1%} / {np.median(cg):6.1%} / {np.percentile(cg, 95):6.1%}"
              f"   폭 {np.percentile(cg, 95) - np.percentile(cg, 5):5.1%}p   MDD {np.median(md):6.1%} / {md.min():6.1%}")

    print("\n[4] 연도별 수익률 (mom)")
    ys = pd.DataFrame({n: base[n]["eq"].groupby(base[n]["eq"].index.year).last() for n in (3, 5, 10, 20)})
    yr = ys.pct_change(); yr.iloc[0] = ys.iloc[0] - 1
    print((yr * 100).round(1).to_string())

    print("\n[5] 종목 간 상관 - 일간 수익률 평균 쌍상관")
    ret = pd.DataFrame(f["c"], index=cal).pct_change(fill_method=None)
    def paircorr(x):
        m = x.corr().values
        return m[np.triu_indices_from(m, 1)].mean()
    print(f"  전체 {START}~        {paircorr(ret[ret.index >= START]):.2f}")
    print(f"  2020-02-20~2020-03-19  {paircorr(ret['2020-02-20':'2020-03-19']):.2f}")
    print(f"  2024-07-11~2024-08-05  {paircorr(ret['2024-07-11':'2024-08-05']):.2f}")

    print("\n[6] 참고 - 코스피 지수(KS11) 같은 기간")
    ks = fdr.DataReader("KS11", START, ASOF)["Close"]
    ks = ks[ks.index <= ASOF] if ASOF else ks
    y = (ks.index[-1] - ks.index[0]).days / 365.25
    print(f"  CAGR {(ks.iloc[-1] / ks.iloc[0]) ** (1 / y) - 1:.1%}  MDD {(ks / ks.cummax() - 1).min():.1%}")

    print("\n[7] MDD 구간 (고점 -> 저점)")
    eq = base[20]["eq"]; dd = eq / eq.cummax() - 1
    print(f"  저점 {dd.idxmin().date()}  고점 {eq[:dd.idxmin()].idxmax().date()}")
    for n in (3, 5, 10, 20):
        e = base[n]["eq"]; d = e / e.cummax() - 1
        print(f"  slots {n:>2}: 저점 {d.idxmin().date()}  고점 {e[:d.idxmin()].idxmax().date()}  {d.min():.1%}")

    print("\n[8] 신호가 같이 뜬 수별 거래 성과 (한도 100 = 신호를 전부 받은 경우)")
    print("same-day signals  trades  share   win%    exp")
    tr, br = base[100]["tr"], base[100]["breadth"]
    for lab, lo, hi in (("1-2", 1, 2), ("3-5", 3, 5), ("6-10", 6, 10), ("11-20", 11, 20), ("21+", 21, 999)):
        m = (br >= lo) & (br <= hi)
        print(f"{lab:>12}  {m.sum():>8}  {m.mean():5.0%}  {(tr[m] > 0).mean():5.0%}  {tr[m].mean():6.2%}")
    print("  잡은 거래 중 '신호 6개 이상 뜬 날' 거래의 비중:",
          "  ".join(f"{n}:{(base[n]['breadth'] >= 6).mean():.0%}" for n in (3, 5, 10, 20, 100)))
    print("  한도 100에서 상위 1% 거래를 빼면 기대값:", f"{np.sort(tr)[:-int(len(tr) * 0.01)].mean():.2%}", " 전체:", f"{tr.mean():.2%}")
