「RSI 14 / 30 / 70」 처럼 RSI 자체를 전략으로 쓰는 설정을 10코인 두 시장에 전수로 걸면(설정 18종 × 일봉 필터 둘 × 10코인 = 360칸) 우리 잣대(두 시장 연 30% 이상 · 깊은 쪽 최대낙폭 −35% 이내)를 넘는 칸이 0칸입니다. 대신 이미 돌아가는 추세 봇의 진입 자리에 RSI 한 줄을 얹으면 이야기가 달라집니다. 이 페이지는 그 한 줄과, 그게 무엇을 못 하는지까지 전부 공개합니다.
ta.rsi(close, 14) > 78 를 AND 로 더합니다 —
신호가 켜져도 바로 사지 않고 이 조건이 참이 되는 첫 봉에 삽니다. 청산은 그대로(슈퍼트렌드가 꺼지는 봉)| RSI 를 그대로 전략으로 (360칸) | 두 시장 min 연복리 중앙 | 잣대 통과 |
|---|---|---|
| 일봉 필터 없이 | -38.2% | 0칸 |
| 일봉 30일선 필터를 켜고 | -10.5% | 0칸 |
| 가장 나은 설정 — RSI14 lo20 hi60 (필터 켬) | -3.5% | — |
| 가장 나쁜 설정 — RSI2 lo30 hi60 (필터 끔) | -54.4% | — |
| 그냥 보유 | 14.7% | — |
바이낸스 BTCUSDT · 업비트 KRW-BTC · 4시간봉 · 편도 0.1% · 다음 봉 체결 · 워밍업 400봉 · 성적 구간 2019-11-06~2026-09-06(6.83년) · 헤드라인은 두 시장 중 나쁜 쪽입니다.
| 두 시장 min 연복리 / worst 최대낙폭 | 연복리 | 최대낙폭 | 잣대 |
|---|---|---|---|
| 봇 (RSI 없음) | 32.9% | -36.7% | 탈락 |
| 봇 + 한 줄 | 33.6% | -22.5% | 통과 |
| 바이낸스 BTCUSDT (1,000만원 시작) | 연복리 | 최대낙폭 | 노출 | Calmar | 거래 | 최종 잔고 |
|---|---|---|---|---|---|---|
| 그냥 보유 | 36.9% | -77.0% | 1.00 | 0.48 | 1회 | 8,547만원 |
| 봇 — 슈퍼트렌드 + 일봉 30일선 | 32.9% | -36.7% | 0.38 | 0.90 | 201회 | 6,986만원 |
| 봇 + 한 줄 | 33.6% | -22.5% | 0.16 | 1.49 | 45회 | 7,218만원 |
| 업비트 KRW-BTC (1,000만원 시작) | 연복리 | 최대낙폭 | 노출 | Calmar | 거래 | 최종 잔고 |
|---|---|---|---|---|---|---|
| 그냥 보유 | 40.3% | -74.8% | 1.00 | 0.54 | 1회 | 1억 114만원 |
| 봇 — 슈퍼트렌드 + 일봉 30일선 | 58.1% | -31.2% | 0.38 | 1.86 | 181회 | 2억 2,824만원 |
| 봇 + 한 줄 | 46.4% | -19.6% | 0.17 | 2.37 | 46회 | 1억 3,544만원 |
거래 201회 → 45회(업비트 181 → 46회) · 노출 0.38 → 0.16 · 승률 37.8% → 51.1%
낙폭 — '낙폭이 얕아진 건 시장에 덜 있어서 아니냐'
낙폭으로 물었으니 낙폭으로 답합니다. 같은 평균 노출을 만드는 계좌를 나란히 세웠습니다.
| 바이낸스 · 같은 평균 노출 0.16 | 최대낙폭 | 연복리 | 1,000만원 → |
|---|---|---|---|
| 봇 + 한 줄 | -22.5% | 33.6% | 7,218만원 |
| 그냥 34봉 늦게 사기 | -23.3% | 20.5% | 3,574만원 |
| 같은 참-비율 무작위 조건 30회(중앙) | -32.3% | 20.1% | — |
| 비중만 줄여 늘 들고 있기 (w=0.42) | -17.0% | 14.2% | 2,481만원 |
| 업비트 · 같은 평균 노출 0.17 | 최대낙폭 | 연복리 | 1,000만원 → |
|---|---|---|---|
| 봇 + 한 줄 | -19.6% | 46.4% | 1억 3,544만원 |
| 그냥 33봉 늦게 사기 | -23.2% | 28.2% | 5,472만원 |
| 같은 참-비율 무작위 조건 30회(중앙) | -25.7% | 32.4% | — |
| 비중만 줄여 늘 들고 있기 (w=0.44) | -14.5% | 23.3% | 4,189만원 |
10코인 × 2시장 × 전략 4종 · 같은 한 줄을 얹었을 때 · 72/80 칸에서 낙폭이 얕아졌지만 수익이 는 칸은 28/80 입니다.
| 전략 (20칸씩) | 연복리 변화 중앙 | 최대낙폭 변화 중앙 | 낙폭이 얕아진 칸 |
|---|---|---|---|
| 슈퍼트렌드 | -0.6%p | +12.1%p | 18/20 |
| 돈치안 | +3.9%p | +11.4%p | 18/20 |
| UT봇 | -12.5%p | +12.7%p | 19/20 |
| 켈트너 | -10.6%p | +10.9%p | 17/20 |
| 비용을 올리면 (두 시장 min 연복리) | 연복리 | 잣대 |
|---|---|---|
| 편도 0.1% | 33.6% | 통과 |
| 편도 0.2% | 31.8% | 통과 |
| 편도 0.3% | 30.1% | 통과 |
| 편도 0.5% | 26.7% | 탈락 |
시세를 함께 얼려 두었습니다. 인터넷 없이 python run_rsi.py 로 위 표가 그대로 나옵니다.
# -*- 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()
시세 파일: BTCUSDT_4h.csv ·
KRW-BTC_4h.csv
(같은 폴더의 data_rsi/ 안에 두세요)
한 줄을 아는 것과 6.83년 동안 그대로 지키는 것은 다른 일입니다. 이 계좌는 시장 안에 있던 시간이 10% 뿐이었습니다 — 나머지 시간은 아무것도 안 하고 기다린 시간입니다. 사람은 그 시간을 못 견딥니다. 알고랩은 전략 구현부터 자동매매 운영까지 대신해 드립니다.
본 페이지의 모든 수치는 과거 데이터 기반 시뮬레이션이며 투자 권유가 아닙니다. 특정 종목·전략의 미래 수익을 보장하지 않습니다. 투자 판단과 책임은 투자자 본인에게 있습니다.