2017년 12월부터 2026년 8월까지, 업비트 4시간봉에서 비트코인과 이더리움을 자동매매한 백테스트입니다. 트레이딩뷰 인기 전략 13종에 자산 13개·시간봉 3개·설정값을 조합한 후보 1,887개를 전부 돌렸고, 연복리 30% 이상 · 최대낙폭 30% 이내를 업비트와 바이낸스 두 거래소에서 동시에 통과한 조합 하나가 남았습니다. 규칙·설정값·재현 코드를 전부 공개합니다.
기준은 두 가지 — 연복리 수익률 30% 이상, 최대낙폭 30% 이내 (비용 차감 후, 전 구간).
| 단계 | 내용 | 통과 |
|---|---|---|
| 전수 시험 | 전략 13종 × 자산 13개 × 시간봉 3개 × 설정값 = 1,887개 | — |
| 1차 결과 | 수익률 기준만 통과 445개 · 낙폭 기준만 통과 137개 | 0개 |
| 조건 한 줄 추가 | 가격이 일봉 이동평균선 위에 있을 때만 진입(하락장 차단) | 185개 |
| 교차시장 검증 | 업비트 그리고 바이낸스에서 모두 기준을 넘는 설정만 | 최종 1조합 |
업비트 단일 기준 1위(연복리 89.8%)는 버렸습니다. 같은 규칙이 바이낸스에서는 29.0%로 떨어졌기 때문입니다 — 두 시장의 포지션이 95.5% 일치하는데 수익이 3배 넘게 갈린다는 건, 수익이 소수의 급등 구간에 몰려 있어 요행에 가깝다는 뜻입니다. 후보를 1,887개씩 돌리면 이런 요행 1등이 반드시 나오므로, 걸러내는 장치가 필요합니다.
전부 공개합니다. 숨길 게 없어야 검증도 됩니다.
4시간봉에서 가격이 [EMA n + k × ATR n] 상단을 넘으면 전액 매수,
EMA 아래로 내려오면 전량 매도(현금). 숏 없음.
설정: 비트코인 n=50, k=1.25 · 이더리움 n=60, k=2.0
가격이 일봉 100일 이동평균선(= 4시간봉 600개 평균) 위에 있을 때만 진입. 큰 흐름이 하락이면 아예 들어가지 않습니다. 이더리움이 85.4% 무너진 2018년을 이 조합은 -10.3%로 지나갔습니다.
자금을 반씩 나눠 각자의 설정으로 돌립니다. 2017-12~2026-08 105개월 중 27개월은 매매 없이 현금으로 대기했습니다.
비중이 바뀔 때마다 편도 0.1%(수수료+슬리피지) 차감. 신호는 봉 종가 확정 후 다음 봉부터 반영(미래참조 방지). 편도 0.3%로 올려도 기준을 유지합니다.
같은 규칙, 두 거래소. 기준일 2026-08-31 고정.
| 구간 | 업비트 원화 | 바이낸스 USDT |
|---|---|---|
| 전 구간 (2017-12 ~ 2026-08) | 연복리 +58.0% · 낙폭 -29.6% | 연복리 +41.7% · 낙폭 -29.4% |
| 2024-01 ~ 2026-08 만 잘라서 | 연복리 +40.8% · 낙폭 -17.1% | 연복리 +19.0% · 낙폭 -23.4% |
| 1000만원이라면 | 5억 4341만원 | 2억 1882만원 |
| 연도 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|---|---|---|---|---|---|---|---|---|---|
| 이 조합 (업비트) | -10% | +138% | +214% | +154% | -6% | +36% | +101% | +18% | +4% |
| 그냥 반반 보유 | -82% | +45% | +368% | +227% | -65% | +135% | +103% | -9% | -21% |
그냥 보유도 8.7배가 됐던 시장입니다. 이 조합의 값어치는 수익률만이 아니라 -82%, -65% 해를 -10%, -6%로 지나간 것 — 계좌가 견딜 수 있는 낙폭 안에서 54.3배를 만든 데 있습니다.
업비트·바이낸스 공개 API 에서 데이터를 직접 받아 위 숫자를 그대로 재현합니다. API 키 불필요.
파이썬 설치 — python.org에서 3.8 이상. 설치 때 Add to PATH 체크.
numpy 설치 — 터미널에서 pip install numpy
실행 — 아래 코드를 받아 python discovery_backtest.py. 첫 실행은 캔들 수집에 3~5분 걸리고, 같은 폴더에 캐시되어 두 번째부터는 몇 초입니다.
# -*- coding: utf-8 -*-
# =============================================================================
# AlgoLab · 전략 1,887개 발굴 백테스트 — 공개용
# 영상 「1,000만원 코인 자동매매, 1,887개 백테스트」의 최종 조합 숫자를 그대로 재현합니다.
# 믿지 말고 직접 돌려보세요. 설정값을 바꿔 여러분의 조건으로 실험해도 됩니다.
#
# 실행: python discovery_backtest.py
# 필요: Python 3.8+ / numpy (pip install numpy)
# 데이터는 업비트·바이낸스 공개 API 에서 자동으로 받습니다(API 키 불필요).
# 첫 실행은 캔들 수집에 3~5분 걸립니다(같은 폴더에 CSV 로 캐시되어 두 번째부터는 몇 초).
#
# ── 최종 조합 (영상 그대로) ──────────────────────────────────────────────────
# · 4시간봉 켈트너 채널 돌파: 가격이 [EMA n + k x ATR n] 상단을 넘으면 전액 매수,
# EMA 아래로 내려오면 전량 매도(현금). 숏 없음.
# · 추세 필터: 가격이 일봉 100일 이동평균선(= 4시간봉 600개 SMA) 위에 있을 때만 진입.
# · 설정: 비트코인 n=50, k=1.25 / 이더리움 n=60, k=2.0. 자금은 반반, 봉마다 리밸런스 가정.
# · 비용: 비중이 바뀔 때마다 편도 0.1% (업비트 수수료 0.05% + 슬리피지 가정).
# · 신호는 봉 종가로 확정하고 **다음 봉부터** 반영합니다(미래참조 방지).
#
# ── 기준일(ASOF) 고정 ────────────────────────────────────────────────────────
# ASOF 이후 데이터를 자르지 않으면 실행 시점마다 숫자가 달라져 영상과 어긋납니다.
# 최신까지 보고 싶으면 ASOF = None 으로 바꾸세요(영상 숫자와는 달라집니다).
#
# ── 이 계산의 한계 (영상에서도 밝힙니다) ─────────────────────────────────────
# · 설정값은 과거 데이터 전체(2017-12~2026-08)에서 골랐습니다. 과최적화에서
# 완전히 자유롭지 않고, 미래 수익을 보장하지 않습니다.
# · 4시간봉 종가 체결을 가정합니다. 실제 체결가는 다를 수 있습니다.
# · 과거 9년에는 2020~21 암호화폐 초강세장이 포함되어 있습니다.
# · 거래소가 과거 캔들을 미세 보정하는 경우가 있어, 배수의 마지막 자리에서
# 0.1~0.2 수준의 차이가 날 수 있습니다(예: 54.3배 → 54.4배). 연복리·낙폭은 일치합니다.
# =============================================================================
import csv, datetime, json, os, sys, time, urllib.parse, urllib.request
import numpy as np
try: # 윈도우 기본 콘솔(cp949)에서도 죽지 않게
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ASOF = "2026-08-31T12:00:00" # UTC. 이 봉(포함)까지 사용 — 영상 제작 시점과 동일
COST = 0.001 # 편도 0.1%
HERE = os.path.dirname(os.path.abspath(__file__))
# ── 데이터 수집 (캐시 → 없으면 API. ASOF 자르기는 양쪽에 똑같이 적용) ────────
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):
rows, to = [], ""
while True:
url = ("https://api.upbit.com/v1/candles/minutes/240?market=" + market +
"&count=200" + ("&to=" + urllib.parse.quote(to) if to else ""))
batch = _get(url)
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()
return rows
def fetch_binance(symbol):
rows, start = [], int(datetime.datetime(2017, 8, 1).timestamp() * 1000)
while True:
url = ("https://api.binance.com/api/v3/klines?symbol=" + symbol +
"&interval=4h&limit=1000&startTime=" + str(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)
return rows
def load(tag, fetch):
path = os.path.join(HERE, "cache_" + tag + ".csv")
if not os.path.exists(path):
print(" " + tag + " 수집 중…")
rows = fetch()
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["date", "open", "high", "low", "close"])
w.writerows(rows)
ts, o, h, l, c = [], [], [], [], []
for r in csv.DictReader(open(path, encoding="utf-8")):
if ASOF and r["date"] > ASOF: # ★ 캐시든 신규든 같은 기준으로 자른다
continue
ts.append(r["date"])
o.append(float(r["open"])); h.append(float(r["high"]))
l.append(float(r["low"])); c.append(float(r["close"]))
return ts, np.array(o), np.array(h), np.array(l), np.array(c)
# ── 지표 ─────────────────────────────────────────────────────────────────────
def ema(x, n):
a = 2.0 / (n + 1)
out = np.empty(len(x)); out[0] = x[0]
for i in range(1, len(x)):
out[i] = a * x[i] + (1 - a) * out[i - 1]
return out
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)))
out = np.full(len(tr), np.nan)
if len(tr) >= n:
out[n - 1] = tr[:n].mean()
for i in range(n, len(tr)):
out[i] = out[i - 1] + (tr[i] - out[i - 1]) / n
return out
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
# ── 전략: 켈트너 돌파 + 일봉 100일선 필터 ────────────────────────────────────
# 켈트너 '상태'(돌파~이탈)와 필터를 따로 두고 곱한다. 필터 때문에 나갔다가
# 가격이 100일선 위로 돌아오면, 켈트너 상태가 살아 있는 한 다시 들어간다.
def signal(o, h, l, c, n, k):
e = ema(c, n); a = atr(h, l, c, n)
f100 = sma(c, 600) # 4시간봉 600개 = 100일
sig = np.zeros(len(c)); base = 0.0
for i in range(len(c)):
if np.isnan(a[i]):
sig[i] = 0.0; continue
if c[i] > e[i] + k * a[i]: # 돌파 → 상태 ON (같은 봉에선 매수 우선)
base = 1.0
elif c[i] < e[i]: # EMA 이탈 → 상태 OFF
base = 0.0
ok = (not np.isnan(f100[i])) and c[i] > f100[i]
sig[i] = base if ok else 0.0
if len(sig) > n:
sig[:n + 1] = 0.0 # 워밍업
return sig
def growth(c, sig):
ret = np.zeros(len(c)); ret[1:] = c[1:] / c[:-1] - 1
pos = np.r_[0.0, sig[:-1]] # 다음 봉 반영
return pos, (1 + pos * ret) * (1 - COST * np.abs(np.diff(np.r_[0.0, pos])))
def stats(g, ts):
eq = np.cumprod(g)
yrs = (datetime.date.fromisoformat(ts[-1][:10])
- datetime.date.fromisoformat(ts[0][:10])).days / 365.25
mdd = float((eq / np.maximum.accumulate(eq) - 1).min())
return float(eq[-1] ** (1 / yrs) - 1), mdd, float(eq[-1])
def run(tag, btc, eth):
tb, ob, hb, lb, cb = btc
te, oe, he, le, ce = eth
WARM = 400
_, gb = growth(cb, signal(ob, hb, lb, cb, 50, 1.25))
_, ge = growth(ce, signal(oe, he, le, ce, 60, 2.0))
ib_map = {t: i for i, t in enumerate(tb)}
pairs = [(ib_map[t], i) for i, t in enumerate(te) if t in ib_map]
ib = np.array([x[0] for x in pairs]); ie = np.array([x[1] for x in pairs])
m = (ib >= WARM) & (ie >= WARM)
gc = 0.5 * gb[ib[m]] + 0.5 * ge[ie[m]]
dl = [te[i] for i in ie[m]]
cagr, mdd, mult = stats(gc, dl)
print("\n[" + tag + "] " + dl[0][:10] + " ~ " + dl[-1][:10])
print(" 반반 조합: 연복리 {:+.1%} · 최대낙폭 {:+.1%} · {:.1f}배 (1000만원 → {:,.0f}만원)"
.format(cagr, mdd, mult, mult * 1000))
yr = {}
for i, t in enumerate(dl):
yr[t[:4]] = yr.get(t[:4], 1.0) * gc[i]
print(" 연도별: " + " · ".join(y + " {:+.0f}%".format((v - 1) * 100)
for y, v in sorted(yr.items())))
if __name__ == "__main__":
print("전략 1,887개 발굴 편 — 최종 조합 재현 (기준일 " + str(ASOF) + " UTC)")
up_btc = load("upbit_btc", lambda: fetch_upbit("KRW-BTC"))
up_eth = load("upbit_eth", lambda: fetch_upbit("KRW-ETH"))
run("업비트 원화", up_btc, up_eth)
try:
bn_btc = load("binance_btc", lambda: fetch_binance("BTCUSDT"))
bn_eth = load("binance_eth", lambda: fetch_binance("ETHUSDT"))
run("바이낸스 USDT", bn_btc, bn_eth)
except Exception as e:
print("\n[바이낸스] 수집 실패(" + str(e) + ") — 지역에 따라 API 가 막힐 수 있습니다. 업비트 결과만 확인하세요.")
print("\n영상 수치: 업비트 +58.0% / -29.6% / 54.3배 · 바이낸스 +41.7% / -29.4%")
신호는 4시간마다, 한밤중에도 나옵니다. 105개월 중 27개월은 아무것도 안 하고 기다려야 했고, 기다리다 신호가 켜지면 새벽이라도 사야 합니다. 이걸 8년 넘게 손으로 하는 사람은 없습니다 — 이게 자동매매가 필요한 이유입니다.
직접 만들어 보실 분은 위 코드에서 출발하시면 됩니다. 구현·검증까지 맡기고 싶으시면 알고랩이 여러분의 전략을 봇으로 만들어 드립니다. 상담과 견적은 무료입니다.
※ 본 페이지의 수치는 공개된 시장 데이터를 과거 기준으로 검증한 교육·검증 목적의 자료이며, 특정인에 대한 투자권유나 매수·매도 권유가 아닙니다. 과거 백테스트 성과가 미래 수익을 보장하지 않습니다. 설정값은 과거 데이터에서 선택되어 과최적화 위험이 있으며, 암호화폐는 원금 전액 손실이 가능한 고위험 자산입니다. 모든 투자 판단과 그에 따른 책임은 이용자 본인에게 있습니다.