# -*- coding: utf-8 -*- """참고 전략 — 교과서에 나오는 규칙들을 같은 채점기로 돌린 되돌아보기. 왜 넣는가 시즌 초에는 참가자 곡선이 없어 비교 차트가 비어 있다. 그러면 "얼마를 내야 잘한 것인가"를 가늠할 기준이 화면에 없다. 널리 알려진 규칙 몇 개를 최근 구간에 적용해 깔아 두면 그 자리가 척도가 된다. 🔴 이것은 **과거 구간 재현**이다. 미래 성과가 아니고, 참가자 성적도 아니다. 화면에 그 사실을 반드시 함께 적는다. 백테스트를 성적처럼 보여주는 것이 이 대회가 없애려는 문제 그 자체다. 🔑 참가자와 **완전히 같은 채점기·같은 수수료**로 돌린다. 규칙만 다르고 나머지 조건이 같아야 비교가 성립한다. 수수료를 빼고 그리면 회전이 높은 규칙이 부당하게 좋아 보인다. 🔑 어떤 규칙도 미래를 보지 않는다. 모든 신호는 shift(1) 이후에 쓴다. 이동평균을 그날 종가로 계산해 그날 포지션에 쓰면 성적이 통째로 거짓이 된다. """ import numpy as np import pandas as pd # 올해 첫 거래일부터 잰다. "1월 1일에 이 규칙으로 출발했다면 지금 어디인가" 가 # 굴러가는 90일 창보다 훨씬 읽기 쉽고, 기준점이 고정되어 매일 값이 흔들리지 않는다. SINCE = "2026-01-01" def _rsi(p, n=14): d = p.diff() up = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean() dn = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean() return 100 - 100 / (1 + up / dn.replace(0, np.nan)) def _weights(px): """규칙별 포지션 시계열. 전부 전날까지의 정보만 쓴다.""" p = pd.Series(px, dtype=float).sort_index() out = {} out["BUYHOLD"] = pd.Series(1.0, index=p.index) # 200일선 — 단일 규칙으로는 가장 널리 알려진 것. 위면 들고 아래면 비운다. s200 = p.rolling(200).mean() out["SMA200"] = (p > s200).astype(float).shift(1) # 골든/데드 크로스 — 50일선이 200일선을 넘으면 롱, 내려가면 숏 s50x = p.rolling(50).mean() out["GOLDEN50_200"] = np.sign(s50x - s200).shift(1) # 이동평균 교차 — 가장 널리 알려진 추세 규칙 s20, s50 = p.rolling(20).mean(), p.rolling(50).mean() out["SMA20_50"] = np.sign(s20 - s50).shift(1) # 60일 모멘텀 — 학술 문헌에서 가장 많이 검증된 축 out["MOM60"] = np.sign(p / p.shift(60) - 1).shift(1) # 돈치안 20일 채널 돌파 hi, lo = p.rolling(20).max(), p.rolling(20).min() br = pd.Series(0.0, index=p.index) br[p >= hi] = 1.0 br[p <= lo] = -1.0 out["DONCHIAN20"] = br.replace(0.0, np.nan).ffill().fillna(0.0).shift(1) # RSI 역추세 — 과매도에 사고 과매수에 판다 r = _rsi(p) out["RSI14"] = ((50 - r) / 20).clip(-1, 1).shift(1) # 볼린저 복귀 — 평균에서 멀어진 만큼 반대로 m, sd = p.rolling(20).mean(), p.rolling(20).std() out["BBAND20"] = (-(p - m) / (2 * sd.replace(0, np.nan))).clip(-1, 1).shift(1) # MACD (12,26,9) — 오실레이터 중 가장 널리 쓰이는 것. # 히스토그램 부호로 방향을 정한다. e12 = p.ewm(span=12, adjust=False).mean() e26 = p.ewm(span=26, adjust=False).mean() macd = e12 - e26 out["MACD"] = np.sign(macd - macd.ewm(span=9, adjust=False).mean()).shift(1) # 일목균형표 구름 — 종가만 있으므로 고저 대신 종가의 이동 최고·최저를 쓴다. # 원본 정의와 완전히 같지는 않지만 구름의 위치 판정은 같은 뜻을 유지한다. conv = (p.rolling(9).max() + p.rolling(9).min()) / 2 base = (p.rolling(26).max() + p.rolling(26).min()) / 2 spa = ((conv + base) / 2).shift(26) spb = ((p.rolling(52).max() + p.rolling(52).min()) / 2).shift(26) top, bot = pd.concat([spa, spb], axis=1).max(axis=1), pd.concat([spa, spb], axis=1).min(axis=1) ich = pd.Series(0.0, index=p.index) ich[p > top] = 1.0 ich[p < bot] = -1.0 out["ICHIMOKU"] = ich.shift(1) # 12개월 절대 모멘텀 — 지난 1년 수익이 양수면 보유, 아니면 비운다. out["ABSMOM12M"] = (p / p.shift(252) - 1 > 0).astype(float).shift(1) # 스토캐스틱 (14,3) — 과매수·과매도 판정의 고전 lo14, hi14 = p.rolling(14).min(), p.rolling(14).max() k = 100 * (p - lo14) / (hi14 - lo14).replace(0, np.nan) out["STOCH14"] = ((50 - k.rolling(3).mean()) / 25).clip(-1, 1).shift(1) # 변동성 타겟팅 — 방향은 롱 고정, 크기만 조절 sig = np.log(p).diff().ewm(span=60).std() tgt = sig.iloc[:max(len(sig) // 2, 60)].median() out["VOLTARGET"] = (tgt / sig).clip(0, 1).shift(1) return {k: v.fillna(0.0) for k, v in out.items()} LABEL = { "BUYHOLD": {"ko": "단순 보유", "en": "Buy and hold"}, "SMA200": {"ko": "200일선 위에서만 보유", "en": "Hold only above the 200-day MA"}, "GOLDEN50_200": {"ko": "골든크로스 50/200", "en": "Golden cross 50/200"}, "SMA20_50": {"ko": "이동평균 20/50 교차", "en": "SMA 20/50 crossover"}, "MOM60": {"ko": "60일 모멘텀", "en": "60-day momentum"}, "DONCHIAN20": {"ko": "돈치안 20일 돌파", "en": "Donchian 20 breakout"}, "RSI14": {"ko": "RSI 14 역추세", "en": "RSI 14 mean reversion"}, "BBAND20": {"ko": "볼린저 20 복귀", "en": "Bollinger 20 reversion"}, "MACD": {"ko": "MACD 12/26/9", "en": "MACD 12/26/9"}, "ICHIMOKU": {"ko": "일목균형표 구름", "en": "Ichimoku cloud"}, "ABSMOM12M": {"ko": "12개월 절대 모멘텀", "en": "12-month absolute momentum"}, "STOCH14": {"ko": "스토캐스틱 14/3", "en": "Stochastic 14/3"}, "VOLTARGET": {"ko": "변동성 타겟팅", "en": "Volatility targeting"}, } ORDER = ["BUYHOLD", "SMA200", "GOLDEN50_200", "SMA20_50", "MACD", "ICHIMOKU", "MOM60", "ABSMOM12M", "DONCHIAN20", "RSI14", "STOCH14", "BBAND20", "VOLTARGET"] def curves(px, asset, scoring, lang="ko", since=SINCE): """올해 첫 거래일부터 지금까지. 참가자와 같은 엔진·같은 수수료로 계산한다. 🔑 지표 계산에는 그 이전 데이터도 쓴다 — 1월 2일의 200일선은 작년 값이 있어야 나온다. 잘라낸 뒤에 지표를 만들면 연초 구간이 통째로 비고, 그러면 규칙이 시작하지도 않은 상태로 그려진다. """ p = pd.Series(px, dtype=float).sort_index() if len(p) < 300: # 252일 모멘텀 + 여유 return {"t": [], "series": []} w = _weights(p) # 전 구간으로 지표를 만든 뒤에 idx = p.index[p.index >= pd.Timestamp(since)] # 표시 구간만 자른다 if len(idx) < 5: return {"t": [], "series": []} seg = p.loc[idx] out = [] for k in ORDER: pos = {t: float(w[k].loc[t]) for t in idx} eq, _ = scoring.equity(pos, seg, asset) out.append({ "key": k, "label": LABEL[k]["en" if lang == "en" else "ko"], "v": [round(float(x - 1) * 100, 4) for x in eq], "ret": round(float(eq.iloc[-1] - 1) * 100, 2), "turnover": round(scoring.turnover(pos, seg), 1), "fee": round(scoring.fee_paid(pos, seg, asset) * 100, 2), }) out.sort(key=lambda x: -x["ret"]) return {"t": [t.isoformat() for t in idx], "series": out, "since": str(idx[0].date()), "window": len(idx), "market": [round(float(x), 4) for x in (seg / seg.iloc[0] - 1) * 100]}