"""
한 번에 팔기 vs 세 번 나눠 팔기 — 같은 진입, 청산만 바꿔 10년 돌린 분할매도 실측
https://algolab.co.kr/blog/split-exit-partial-take-profit-backtest-2026

진입 : 종가가 직전 20거래일 고가를 넘으면(20일 돌파) -> 다음 날 시가 매수
공통 청산 : ① 손절 -8% — 장중 저가가 손절가에 닿으면 손절가 체결(시가부터 아래면 시가)
            ② 추세 이탈 — 종가가 직전 10거래일 저가를 깨면 다음 날 시가 매도
청산 4종 :
  A 추세만      — 익절 없음. ①②로만 나간다 (기준선)
  B 전량 +10%   — +10% 지정가에 전량 매도
  C 1/3 분할    — +5% / +10% / +15% 에 1/3씩 매도. 남은 몫은 ①②로
  D 1/2 + 추적  — +10% 에 절반 매도, 그 뒤 남은 절반의 손절가를 진입가(본전)로 올리고 ②로
비용 : 매수·매도 수수료 각 0.015% + 매도 증권거래세 0.20%(2026년 코스피·코스닥) + 체결마다 슬리피지 0.05%
       -> 비용이 '금액 비례'라 나눠 팔아도 총비용은 거의 같다. 늘어나는 건 주문 건수다.
종목 : 2016년 이전 상장 국내 종목 22개 (지금 살아 있는 종목 = 생존 편향 있음)

같은 날 손절가와 익절가가 둘 다 닿으면 손절이 먼저라고 본다(보수적).
일봉만으로는 장중 순서를 모른다 — 관련 글: /blog/daily-bar-stop-loss-take-profit-same-day-backtest-2026

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

ASOF = "2026-10-16"     # 기준일 고정 — None 이면 실행 시점까지
START = "2016-01-01"
STOP = 0.08
FEE, TAX, SLIP = 0.00015, 0.0020, 0.0005
BUY_COST = FEE + SLIP
SELL_COST = FEE + TAX + SLIP

# 각 청산: [(비중, 익절 수익률)] — 익절이 None 이면 그 몫은 손절·추세 이탈로만 나간다
EXITS = {
    "A 추세만":     dict(legs=[(1.0, None)], be=False),
    "B 전량 +10%":  dict(legs=[(1.0, 0.10)], be=False),
    "C 1/3 분할":   dict(legs=[(1/3, 0.05), (1/3, 0.10), (1/3, 0.15)], be=False),
    "D 1/2 + 추적": dict(legs=[(0.5, 0.10), (0.5, None)], be=True),
}

TICKERS = {
    "005930": "삼성전자", "000660": "SK하이닉스", "005380": "현대차", "000270": "기아",
    "012330": "현대모비스", "035420": "NAVER", "035720": "카카오", "051910": "LG화학",
    "006400": "삼성SDI", "068270": "셀트리온", "105560": "KB금융", "055550": "신한지주",
    "015760": "한국전력", "017670": "SK텔레콤", "030200": "KT", "033780": "KT&G",
    "009150": "삼성전기", "086520": "에코프로", "028300": "HLB", "196170": "알테오젠",
    "041510": "에스엠", "035900": "JYP Ent.",
}


def load(code):
    df = fdr.DataReader(code, "2015-06-01", ASOF)[["Open", "High", "Low", "Close"]].astype(float)
    if ASOF:
        df = df[df.index <= ASOF]          # 캐시·신규 다운로드 모두 같은 자르기
    return df[(df["Open"] > 0) & (df["Low"] > 0)]


def run(df, legs, be):
    o, h, l, c = (df[x].values for x in ("Open", "High", "Low", "Close"))
    hi20 = df["High"].rolling(20).max().shift(1).values
    lo10 = df["Low"].rolling(10).min().shift(1).values
    start_i = int(np.searchsorted(df.index.values, np.datetime64(START)))
    trades, i, n = [], max(start_i, 21), len(df)
    while i < n - 1:
        if not c[i] > hi20[i]:
            i += 1
            continue
        e = i + 1
        entry = o[e]
        stop = entry * (1 - STOP)
        left = [[w, tp] for w, tp in legs]          # 아직 안 판 몫
        fills, mfe, j = [], 0.0, e                  # fills = (비중, 체결가, 사유)
        while left and j < n:
            mfe = max(mfe, h[j] / entry - 1)
            if j > e and o[j] <= stop:              # 갭으로 손절가 아래 출발 -> 남은 몫 시가 체결
                fills += [(w, o[j], "stop") for w, _ in left]; left = []; break
            if j > e:                               # 갭으로 익절가 위 출발 -> 그 몫 시가 체결
                for leg in [x for x in left if x[1] is not None and o[j] >= entry * (1 + x[1])]:
                    fills.append((leg[0], o[j], "tp")); left.remove(leg)
            if left and l[j] <= stop:               # 같은 날 둘 다 닿으면 손절 먼저(보수적)
                px = stop if j == e else min(stop, o[j])
                fills += [(w, px, "stop") for w, _ in left]; left = []; break
            hit = [x for x in left if x[1] is not None and h[j] >= entry * (1 + x[1])]
            for leg in hit:
                fills.append((leg[0], entry * (1 + leg[1]), "tp")); left.remove(leg)
            if hit and be:
                stop = max(stop, entry)             # D: 첫 익절 뒤 남은 몫 손절가를 본전으로
            if left and c[j] < lo10[j] and j + 1 < n:   # 추세 이탈 -> 다음 날 시가
                j += 1
                fills += [(w, o[j], "trend") for w, _ in left]; left = []; break
            j += 1
        if left:                                     # 기준일까지 보유 중 -> 마지막 종가로 평가
            j = n - 1
            fills += [(w, c[j], "open") for w, _ in left]
        gross = sum(w * px for w, px, _ in fills) / entry - 1
        ret = sum(w * (px * (1 - SELL_COST)) for w, px, _ in fills) / (entry * (1 + BUY_COST)) - 1
        trades.append(dict(entry_day=df.index[e], exit_day=df.index[j], entry=entry, gross=gross, ret=ret,
                           orders=1 + len(fills), tp_fills=sum(1 for f in fills if f[2] == "tp"),
                           days=j - e, mfe=mfe, all_stop=all(f[2] == "stop" for f in fills)))
        i = j + 1
    return pd.DataFrame(trades)


def mdd_of(t):
    if t.empty:
        return 0.0
    eq = (1 + t.sort_values("entry_day")["ret"]).cumprod()
    return (eq / eq.cummax() - 1).min()


if __name__ == "__main__":
    rows = []
    for code, name in TICKERS.items():
        df = load(code)
        for label, cfg in EXITS.items():
            t = run(df, cfg["legs"], cfg["be"])
            t["code"], t["name"], t["exit"] = code, name, label
            rows.append(t)
    T = pd.concat(rows, ignore_index=True)
    print(f"ASOF {ASOF}  tickers {len(TICKERS)}  trades total {len(T):,}  "
          f"round-trip cost {BUY_COST + SELL_COST:.2%}")

    print("\n[1] 청산별 요약 (22종목 거래 합산)")
    print("exit           trades   win%  expectancy  avg_win  avg_loss  hold_days  sell_orders  cost_per_trade")
    for label in EXITS:
        x = T[T.exit == label]
        cost = (x.gross - x.ret).mean()
        print(f"{label:12s} {len(x):7d} {(x.ret > 0).mean():6.1%} {x.ret.mean():10.2%} "
              f"{x.loc[x.ret > 0, 'ret'].mean():8.2%} {x.loc[x.ret <= 0, 'ret'].mean():8.2%} "
              f"{x.days.mean():9.1f} {(x.orders - 1).mean():11.2f} {cost:13.3%}")

    print("\n[2] 종목별 누적수익 중앙값 / MDD 중앙값 / 1위 횟수")
    P = (T.groupby(["code", "exit"])
           .apply(lambda g: pd.Series(dict(total=(1 + g.ret).prod() - 1, mdd=mdd_of(g))))
           .reset_index())
    best = P.loc[P.groupby("code")["total"].idxmax(), "exit"].value_counts()
    for label in EXITS:
        x = P[P.exit == label]
        print(f"{label:12s} total_med {x.total.median():8.1%}  mdd_med {x.mdd.median():7.1%}  best_in {best.get(label, 0):2d}")

    print("\n[3] 거래를 '최대 얼마까지 올랐나(MFE)'로 나눠 본 거래당 기대값")
    base = T[T.exit == "A 추세만"][["code", "entry_day", "mfe"]]
    T2 = T.merge(base, on=["code", "entry_day"], suffixes=("", "_base"))
    bins = [-1, 0.05, 0.10, 0.15, 0.30, 99]
    names = ["<+5%", "+5~10%", "+10~15%", "+15~30%", "+30%+"]
    T2["bucket"] = pd.cut(T2.mfe_base, bins, labels=names, right=False)
    head = "bucket     n    " + "  ".join(f"{k:>12s}" for k in EXITS)
    print(head)
    for b in names:
        x = T2[T2.bucket == b]
        cells = "  ".join(f"{x[x.exit == k].ret.mean():12.2%}" for k in EXITS)
        print(f"{b:8s} {len(x[x.exit == 'A 추세만']):4d}  {cells}")

    print("\n[4] 연도별 거래당 기대값 + 코스피 연간 등락")
    ks = fdr.DataReader("KS11", "2015-12-01", ASOF)["Close"]
    if ASOF:
        ks = ks[ks.index <= ASOF]
    yr = ks.groupby(ks.index.year).last()
    kret = yr.pct_change()
    T["year"] = pd.to_datetime(T.entry_day).dt.year
    print("year  kospi    " + "  ".join(f"{k:>12s}" for k in EXITS))
    for y in sorted(T.year.unique()):
        x = T[T.year == y]
        cells = "  ".join(f"{x[x.exit == k].ret.mean():12.2%}" for k in EXITS)
        print(f"{y}  {kret.get(y, float('nan')):6.1%}  {cells}")

    print("\n[5] 분할 C의 익절 체결 분포 (몇 번째 몫까지 팔렸나)")
    x = T[T.exit == "C 1/3 분할"]
    print(x.tp_fills.value_counts().sort_index().to_string())
