전략 발굴 · 2026-09-06

45억 795만원을 만들고도 탈락한 전략,
장치 하나로 통과시켰습니다

업비트 이더리움 4시간봉에서 8.7년을 돌리면 1000만원이 45억 795만원이 되는 규칙이 있었습니다. 그런데 같은 규칙을 바이낸스에서 돌리면 최대낙폭이 -53.4%였습니다. 저희 잣대는 두 시장 중 나쁜 쪽으로 채점하므로, 이 전략은 탈락이었습니다.

그래서 낙폭을 줄인다는 장치를 22개 모아 전부 얹어 봤습니다. 트레일링 손절 · 샹들리에 · 분할 청산 · 분할 진입 · 서킷 브레이커 · 변동성 타겟. 원래 전략까지 합쳐 23칸을 두 시장에서 다시 돌렸고, 기준을 통과한 것은 1칸이었습니다. 그리고 그 하나는 손절 계열이 아니었습니다.

원래 전략 · 업비트45억 795만원450.8배 · 연복리 100.9%
원래 전략 · 나쁜 쪽 낙폭-53.4%기준 -35% 초과 → 탈락
변동성 타겟 · 나쁜 쪽 낙폭-33.2%20.2%p 개선 → 통과
변동성 타겟 · 업비트10억 6,221만원106.2배 · 연복리 70.3%

잣대 — 결과를 보고 바꾸지 않습니다

아래 세 줄은 지난 편과 한 글자도 다르지 않습니다.

· 업비트와 바이낸스 두 시장 모두 연복리 30% 이상

· 두 시장 모두 최대낙폭 -35% 이내

· 매매마다 편도 0.1%의 거래 비용

전략 규칙 — 전부 공개합니다

FVG 되돌림 (look 20 / exit 20) + 일봉 SMA50 필터 · 4시간봉 · 비용 편도 0.1%

1. 가격이 한 번에 크게 튀어 캔들 사이에 빈 구간(갭)이 생기고, 20봉 안에 가격이 그 갭 자리로 되돌아오면 삽니다.

2. 종가가 갭 하단 아래로 내려가거나 20봉 지수이동평균 아래로 내려가면 나옵니다.

3. 일봉 50일 이동평균선 위에 있을 때만 삽니다.

4. 최근 42봉(약 일주일) 실현변동성이 연율 50%를 넘으면 그 비율만큼 비중을 줄입니다. — 이번 편이 찾은 장치

변동성이 100%면 비중 0.5, 200%면 0.25가 됩니다. 시장이 조용하면 그대로 다 삽니다. 신호는 언제나 다음 봉에 반영합니다(같은 봉 종가로 사는 미래참조를 막기 위해서입니다).

두 자산 · 두 시장 실측

구간 2017-12-05~2026-09-05 · 검증 열은 최적화에 쓰지 않고 떼어둔 2024년 이후 구간입니다.

대상시장연복리최대낙폭배수1000만원이검증(2024~)잣대
이더리움
장치 없음
업비트100.9%-33.8%450.8배45억 795만원52.6%탈락
나쁜 쪽 연복리 37.5%
나쁜 쪽 낙폭 -53.4%
바이낸스37.5%-53.4%16.8배1억 6,842만원12.9%
이더리움
+ 변동성 타겟 0.5
업비트70.3%-31.0%106.2배10억 6,221만원40.0%통과
나쁜 쪽 연복리 31.4%
나쁜 쪽 낙폭 -33.2%
바이낸스31.4%-33.2%11.2배1억 1,234만원10.3%
비트코인
장치 없음
업비트67.6%-30.5%92.2배9억 2,206만원34.4%탈락
나쁜 쪽 연복리 41.2%
나쁜 쪽 낙폭 -43.1%
바이낸스41.2%-43.1%21.4배2억 1,356만원6.5%
비트코인
+ 변동성 타겟 0.5
업비트55.7%-24.9%48.3배4억 8,331만원31.6%통과
나쁜 쪽 연복리 34.4%
나쁜 쪽 낙폭 -32.1%
바이낸스34.4%-32.1%13.8배1억 3,753만원6.0%

검증 구간에서 바이낸스 연복리가 10.3%로 크게 낮습니다. 숨기지 않고 그대로 싣습니다 — 이 전략의 최근 성적은 원화 시장에 크게 기대고 있습니다.

장치 23칸 전수 결과

이더리움 기준. 정렬은 잣대 통과 → 나쁜 쪽 연복리 순입니다.

장치 · 설정값나쁜 쪽 연복리나쁜 쪽 최대낙폭업비트 배수잣대
변동성타겟
{"target": 0.5}
31.4%-33.2%106.2배통과
트레일링+변타
{"t": {"k": 2.0}, "v": {"target": 0.9}}
40.4%-38.5%316.9배탈락
변동성타겟
{"target": 0.9}
39.6%-39.2%360.2배탈락
ATR트레일링
{"k": 2.0}
38.4%-49.8%392.0배탈락
분할청산
{"levels": [[0.5, 0.5]]}
38.4%-50.2%419.9배탈락
분할청산
{"levels": [[0.5, 0.5], [1.0, 0.25]]}
38.4%-50.2%451.0배탈락
변동성타겟
{"target": 0.7}
37.7%-38.2%244.6배탈락
샹들리에
{"k": 3.0}
37.6%-53.4%350.1배탈락
베이스(그대로)
{"look": 20, "exit_n": 20}
37.5%-53.4%450.8배탈락
ATR트레일링
{"k": 3.0}
37.5%-53.4%445.6배탈락
ATR트레일링
{"k": 4.0}
37.5%-53.4%450.8배탈락
샹들리에
{"k": 4.0}
37.5%-53.4%450.8배탈락
분할청산
{"levels": [[0.3, 0.6], [0.8, 0.3]]}
36.8%-50.8%327.6배탈락
분할진입_시간
{"steps": 2}
33.5%-48.8%325.8배탈락
트레일링+서킷
{"t": {"k": 2.0}, "c": {"loss_n": 3, "rest": 60}}
30.9%-43.1%44.0배탈락
서킷브레이커
{"loss_n": 3, "rest": 60}
30.8%-45.3%49.0배탈락
분할진입_시간
{"steps": 3}
30.8%-49.9%274.3배탈락
분할진입_시간
{"steps": 4}
27.4%-52.6%216.0배탈락
분할진입_눌림
{"first": 0.5, "dip": 0.03}
20.4%-40.0%22.4배탈락
분할진입_눌림
{"first": 0.5, "dip": 0.08}
19.9%-30.7%25.0배탈락
분할진입_눌림
{"first": 0.5, "dip": 0.05}
18.7%-36.7%23.8배탈락
서킷브레이커
{"loss_n": 2, "rest": 30}
18.7%-62.7%42.0배탈락
서킷브레이커
{"loss_n": 2, "rest": 60}
8.7%-51.9%16.3배탈락

장치가 무엇을 하는지

장치규칙
베이스(그대로)장치 없음 — 원래 전략
ATR트레일링보유 중 최고 종가에서 k×ATR 아래로 마감하면 청산
샹들리에기준을 종가가 아니라 최고 고가로 잡은 트레일링
분할청산수익 구간에서 비중을 단계적으로 덜어냄
분할진입_시간한 번에 다 사지 않고 여러 봉에 걸쳐 채움
분할진입_눌림절반만 먼저 사고 눌림에서 나머지
서킷브레이커연속으로 지면 일정 봉수 동안 쉼
변동성타겟실현변동성이 목표를 넘으면 그만큼 비중 축소
트레일링+변타트레일링 손절과 변동성 타겟을 겹쳐 건 것
트레일링+서킷트레일링 손절과 서킷 브레이커를 겹쳐 건 것

왜 손절은 낙폭을 못 줄였을까

트레일링 손절을 걸어도 나쁜 쪽 낙폭은 -53.4%에서 -49.8%까지밖에 안 내려왔습니다. 샹들리에는 그대로였고, 분할 청산도 마찬가지였습니다. 서킷 브레이커는 낙폭을 조금 줄였지만 업비트 배수가 450.8배에서 49.0배로 무너졌습니다.

손절은 이미 들어가 있는 포지션에서 언제 나갈지를 바꾸는 장치입니다. 그런데 이 전략의 낙폭은 늦게 나와서 생긴 게 아니라 들어간 직후부터 하락이 시작돼서 생겼습니다. 손절이 하락을 확인했을 때는 낙폭이 이미 만들어져 있습니다. 바꿔야 할 것은 나가는 시점이 아니라 들어간 크기였습니다.

대가

공짜가 아닙니다. 업비트 기준 45억 795만원이 10억 6,221만원이 됐습니다. 34억 4,574만원을 내주고 낙폭 20.2%p를 산 셈입니다. 낙폭이 아니라 최종 금액이 목적이라면 이 장치는 손해입니다. 저희 잣대가 낙폭을 보는 이유는 단 하나 — 중간에 못 견디고 끄는 전략은 최종 금액을 못 받기 때문입니다.

한계 — 이건 예측이 아닙니다

· 과거 데이터 백테스트입니다. 미래 수익을 보장하지 않습니다.

· 거래 비용은 편도 0.1%만 넣었고 슬리피지·부분체결·세금은 넣지 않았습니다.

· 승률은 30.0% 수준입니다(417건 중). 열 번 중 일곱 번은 지는 전략입니다.

· 이 전략은 여러 후보 가운데 업비트 배수가 가장 컸던 칸에서 출발했습니다. 그래서 두 시장·두 자산·검증 구간을 전부 병기합니다.

· 변동성 타겟의 목표값 0.5는 세 값(0.5 · 0.7 · 0.9) 가운데 유일하게 기준을 통과한 값입니다. 통과한 것이 하나뿐이라는 사실 자체를 위 표에 그대로 공개합니다.

재현 코드 — 전부, 그대로

이 파일 하나로 위 표를 처음부터 다시 만들 수 있습니다. 데이터는 실행할 때 거래소 공개 API 로 직접 받습니다. 기준일은 2026-09-05로 못 박혀 있습니다(ASOF). 최신으로 보려면 ASOF = None.

fvg_voltarget.py 내려받기

# -*- coding: utf-8 -*-
"""
AlgoLab 공개 재현 코드 — FVG 되돌림 전략 + 변동성 타겟 사이징
=============================================================
영상: 「45억 만든 자동매매 전략, 탈락시킨 뒤 살렸습니다」

이 파일 하나만 있으면 영상의 모든 숫자를 처음부터 다시 만들 수 있습니다.
필요한 것: python 3.9+, numpy. 데이터는 실행할 때 거래소 공개 API 로 직접 받습니다.

    pip install numpy
    python fvg_voltarget.py

▣ 기준일 고정 (ASOF)
  영상은 2026-09-05 까지의 데이터로 만들었습니다. 오늘 그냥 돌리면 그 뒤 캔들이
  더 붙어서 숫자가 미세하게 달라집니다. 그래서 기준일을 아래에 못 박았습니다.
  최신 데이터로 보고 싶으면 ASOF 를 None 으로 바꾸세요.

▣ 전략 규칙 (영상에서 말한 그대로, 전부 여기 있습니다)
  1. 상승 갭(FVG)이 생기고 20봉 안에 가격이 그 갭 자리로 되돌아오면 산다
  2. 종가가 갭 하단 아래로 내려가거나 20봉 지수이동평균 아래로 내려가면 나온다
  3. 일봉 50일 이동평균선 위에 있을 때만 산다
  4. 최근 42봉 실현변동성(연율)이 목표 50%를 넘으면 그 비율만큼 비중을 줄인다  ← 이번 편의 장치

▣ 잣대 (결과를 보고 바꾸지 않습니다)
  업비트·바이낸스 두 시장 모두 연복리 30% 이상 · 최대낙폭 -35% 이내 · 편도 거래비용 0.1%
  두 시장 중 나쁜 쪽으로 채점합니다.

▣ 한계
  과거 데이터 백테스트입니다. 슬리피지·부분체결·세금은 넣지 않았습니다.
  승률은 30% 수준입니다(열 번 중 일곱 번은 집니다). 미래 수익을 보장하지 않습니다.
"""
import datetime
import json
import os
import sys
import time
import urllib.request

import numpy as np

sys.stdout.reconfigure(encoding="utf-8", errors="replace")

ASOF = datetime.date(2026, 9, 5)   # 영상을 만든 날. None 이면 오늘까지
CACHE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_data")
FEE = 0.001            # 편도 0.1%
LOOK, EXIT_N = 20, 20  # 갭 되돌림 유효 봉수 / 청산 EMA
FILT_DAYS = 50         # 일봉 이동평균 필터
VT_WIN, VT_TARGET = 42, 0.5   # 변동성 창(봉) / 목표 연율 변동성
WARM = 400             # 지표 워밍업 — 성적은 이 뒤부터만 잰다
VAL_START = "2024-01-01"       # 검증 구간 시작
BARS_PER_DAY = 6       # 4시간봉


# ══════════════════════════════════════════════════════════════════════
# 1) 데이터 — 업비트(원화) · 바이낸스(달러) 4시간봉
# ══════════════════════════════════════════════════════════════════════
def _get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    return json.loads(urllib.request.urlopen(req, timeout=30).read().decode())


def fetch_upbit(market):
    """업비트 240분봉을 최신부터 역방향으로 긁는다."""
    rows, to = [], ""
    while True:
        url = f"https://api.upbit.com/v1/candles/minutes/240?market={market}&count=200"
        if to:
            url += "&to=" + to
        batch = _get(url)
        if not batch:
            break
        for b in batch:
            rows.append([b["candle_date_time_kst"], b["opening_price"],
                         b["high_price"], b["low_price"], b["trade_price"]])
        to = batch[-1]["candle_date_time_utc"]
        if len(batch) < 200 or to < "2017-01-01":
            break
        time.sleep(0.12)
    rows.sort(key=lambda r: r[0])
    return rows


def fetch_binance(symbol):
    """바이낸스 4h klines 를 오래된 쪽부터 긁는다."""
    rows, start = [], int(datetime.datetime(2017, 8, 1).timestamp() * 1000)
    while True:
        batch = _get(f"https://api.binance.com/api/v3/klines?symbol={symbol}"
                     f"&interval=4h&limit=1000&startTime={start}")
        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, float(b[1]), float(b[2]), float(b[3]), float(b[4])])
        if len(batch) < 1000:
            break
        start = batch[-1][0] + 1
    return rows


def load(tag, getter):
    os.makedirs(CACHE, exist_ok=True)
    path = os.path.join(CACHE, tag + ".json")
    if os.path.exists(path):
        rows = json.load(open(path, encoding="utf-8"))
    else:
        print(f"  {tag} 내려받는 중…")
        rows = getter()
        json.dump(rows, open(path, "w", encoding="utf-8"))
    # ⚠ 자르기는 캐시를 쓰든 새로 받든 똑같이 적용한다(한쪽에만 걸면 두 번째 실행부터 달라진다)
    if ASOF:
        cut = ASOF.isoformat() + "T23:59:59"
        rows = [r for r in rows if r[0] <= cut]
    return rows


def series(rows):
    d = [r[0] for r in rows]
    o, h, l, c = (np.array([r[i] for r in rows], float) for i in (1, 2, 3, 4))
    n = len(c)
    ret = np.zeros(n)
    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, n=n, ret=ret, ppy=n / yrs,
                val_i=int(np.searchsorted(np.asarray(d), VAL_START)))


# ══════════════════════════════════════════════════════════════════════
# 2) 전략 — 규칙 1·2·3
# ══════════════════════════════════════════════════════════════════════
def ema(x, n):
    out = np.empty(len(x), float)
    k = 2.0 / (n + 1)
    out[0] = x[0]
    for i in range(1, len(x)):
        out[i] = x[i] * k + out[i - 1] * (1 - k)
    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


def fvg_pullback(o, h, l, c, look=LOOK, exit_n=EXIT_N):
    """상승 갭(3봉: l[t] > h[t-2]) 뒤 look봉 안에 갭 구간으로 되돌아오면 매수.
    청산: 종가가 갭 하단 아래 또는 종가 < EMA(exit_n)."""
    n = len(c)
    e = ema(c, exit_n)
    sig = np.zeros(n)
    pos = 0.0
    z_top = z_bot = np.nan
    z_age = 10 ** 9
    for t in range(2, n):
        if pos == 0.0:
            if l[t] > h[t - 2]:
                z_top, z_bot, z_age = l[t], h[t - 2], 0
            else:
                z_age += 1
            if z_age <= look and not np.isnan(z_top):
                if l[t] <= z_top and c[t] > z_bot:
                    pos = 1.0
                    z_age = 10 ** 9
        else:
            if (not np.isnan(z_bot) and c[t] < z_bot) or c[t] < e[t]:
                pos = 0.0
                z_top = z_bot = np.nan
        sig[t] = pos
    return sig


def with_filter(S):
    """규칙 3 — 일봉 50일선 위에서만 산다(4시간봉이므로 50×6봉)."""
    base = fvg_pullback(S["o"], S["h"], S["l"], S["c"])
    m50 = sma(S["c"], FILT_DAYS * BARS_PER_DAY)
    f = np.zeros(S["n"])
    ok = ~np.isnan(m50)
    f[ok] = (S["c"][ok] > m50[ok]).astype(float)
    return base * f


# ══════════════════════════════════════════════════════════════════════
# 3) 이번 편의 장치 — 변동성 타겟 사이징 (규칙 4)
# ══════════════════════════════════════════════════════════════════════
def vol_target(S, sig, target=VT_TARGET, win=VT_WIN):
    """최근 win봉 실현변동성(연율)이 target 을 넘으면 그 비율만큼 비중을 줄인다.
    변동성 100%면 비중 0.5, 200%면 0.25. 조용하면 그대로 1.0."""
    ppy = int(round(S["ppy"]))
    r, n = S["ret"], S["n"]
    w = np.ones(n)
    for t in range(win, n):
        v = r[t - win + 1:t + 1].std() * np.sqrt(ppy)
        w[t] = min(1.0, target / v) if v > 1e-9 else 1.0
    return sig * w


# ══════════════════════════════════════════════════════════════════════
# 4) 채점
# ══════════════════════════════════════════════════════════════════════
def growth(S, sig, cost=FEE):
    """신호는 **다음 봉**에 반영한다(같은 봉 종가로 사는 미래참조 방지)."""
    pos = np.r_[0.0, sig[:-1]]
    return pos, (1 + pos * S["ret"]) * (1 - cost * np.abs(np.diff(np.r_[0.0, pos])))


def stats(g, ppy):
    eq = np.cumprod(g)
    yrs = len(g) / ppy
    return dict(cagr=eq[-1] ** (1 / yrs) - 1 if eq[-1] > 0 else -1.0,
                mdd=float((eq / np.maximum.accumulate(eq) - 1).min()),
                mult=float(eq[-1]))


def trades(pos, g):
    inpos = pos > 0
    edges = np.diff(inpos.astype(int))
    starts = list(np.where(edges == 1)[0] + 1)
    ends = list(np.where(edges == -1)[0] + 1)
    if inpos[0]:
        starts = [0] + starts
    if len(ends) < len(starts):
        ends.append(len(g) - 1)
    rets = np.array([np.prod(g[s:e + 1]) - 1 for s, e in zip(starts, ends)])
    if not len(rets):
        return 0, float("nan")
    return len(rets), float((rets > 0).mean())


def measure(S, sig):
    pos, g = growth(S, sig)
    st = stats(g[WARM:], S["ppy"])
    stv = stats(g[S["val_i"]:], S["ppy"])
    n, win = trades(pos[WARM:], g[WARM:])
    st.update(val_cagr=stv["cagr"], val_mdd=stv["mdd"], 거래=n, 승률=win,
              비중변경=int(np.sum(np.abs(np.diff(sig[WARM:])) > 1e-9)),
              구간=f'{S["d"][WARM][:10]}~{S["d"][-1][:10]}')
    return st


# ══════════════════════════════════════════════════════════════════════
def main():
    print(f"기준일 ASOF = {ASOF or '오늘'}\n")
    market = {
        "eth": [("업비트", lambda: fetch_upbit("KRW-ETH"), "up_eth"),
                ("바이낸스", lambda: fetch_binance("ETHUSDT"), "bi_eth")],
        "btc": [("업비트", lambda: fetch_upbit("KRW-BTC"), "up_btc"),
                ("바이낸스", lambda: fetch_binance("BTCUSDT"), "bi_btc")],
    }
    for asset in ("eth", "btc"):
        S, BASE = {}, {}
        for tag, getter, key in market[asset]:
            S[tag] = series(load(key, getter))
            BASE[tag] = with_filter(S[tag])

        print(f"\n{'═' * 78}\n  {asset.upper()}  ({S['업비트']['d'][WARM][:10]} ~ "
              f"{S['업비트']['d'][-1][:10]}, 4시간봉, 편도 {FEE * 100:.1f}%)\n{'═' * 78}")
        print(f"  {'':22s}{'연복리':>8}{'최대낙폭':>10}{'배수':>11}"
              f"{'검증 연복리':>12}{'비중변경':>9}")
        for label, make in (("장치 없음 (원래 전략)", lambda t: BASE[t]),
                            ("+ 변동성 타겟 0.5", lambda t: vol_target(S[t], BASE[t]))):
            got = {}
            for tag, _, _ in market[asset]:
                m = measure(S[tag], make(tag))
                got[tag] = m
                print(f"  {label if tag == '업비트' else '':22s}"
                      f"{tag:6s}{m['cagr'] * 100:7.1f}%{m['mdd'] * 100:9.1f}%"
                      f"{m['mult']:10.1f}배{m['val_cagr'] * 100:11.1f}%{m['비중변경']:9d}")
            mn = min(got[t]["cagr"] for t in got)
            wo = min(got[t]["mdd"] for t in got)
            ok = "통과" if (mn >= 0.30 and wo >= -0.35) else "탈락"
            print(f"  {'':22s}→ 나쁜 쪽 연복리 {mn * 100:.1f}% · "
                  f"나쁜 쪽 최대낙폭 {wo * 100:.1f}%  ⇒ 잣대 {ok}")
            print(f"  {'':22s}   거래 {got['업비트']['거래']}건 · "
                  f"승률 {got['업비트']['승률'] * 100:.1f}% (업비트 기준)\n")

    print("\n※ 영상에서 말한 '장치 22개'의 나머지(트레일링·샹들리에·분할청산·분할진입·서킷브레이커)는")
    print("   설명란의 표에 결과를 전부 실었습니다. 낙폭을 기준선 안으로 넣은 것은 변동성 타겟 하나입니다.")


if __name__ == "__main__":
    main()

그래서, 이 규칙을 사람이 지킬 수 있을까

장치 없는 원래 전략은 8.7년 동안 비중을 830번 바꿉니다. 변동성 타겟을 얹으면 3,940번이 됩니다. 4시간마다 변동성을 다시 재고, 살 양을 다시 계산해야 합니다. 그것도 자산이 -33.2%까지 빠지는 구간을 지나면서 말입니다.

낙폭 20.2%p를 줄인 것은 손절이 아니라 규칙을 그대로 지킨 3,940번이었습니다.

비중을 바꾸는 횟수는 데이터를 언제 받느냐에 따라 캔들 한 개 차이로 ±1 씩 달라집니다. 그래서 십 단위로 버려서 적었습니다 — 위 코드를 직접 돌리시면 이 자리에서 한두 개 차이가 날 수 있습니다. 규칙은 위에 전부 있습니다. 직접 받아서 돌려보셔도 되고, 검증하고 구현하고 신호와 주문까지 연결하는 일을 저희에게 맡기셔도 됩니다.

알고랩에 맡기기 코드만 받아가기
Strategy Lab · 이 검증 더 보기
전략 후보 1,887개 전수 백테스트이 전략의 출처 폭락에 팔지 않는 규칙 — −55%를 −23%로위험 관리 전략 검증 전체 보기 →

본 페이지는 과거 데이터에 근거한 백테스트 결과이며 투자 자문이나 수익 보장이 아닙니다. 암호화폐 투자는 원금 손실 위험이 있습니다. 투자 판단과 그 결과는 투자자 본인에게 귀속됩니다.