[6/17] 자동매매 개발일지 – 상태머신과 시그널 엔진

차트를 눈으로 보며 판단하던 ORB+FVG 규칙을 미장봇이 매일 똑같이 실행하도록 상태머신과 시그널 엔진 코드로 옮겼다. 규칙이 어디서 왔는지는 5편에 적었다.

신호는 QQQ에서 읽고 주문은 레버리지 ETF로

신호를 읽는 차트와 주문을 넣는 종목을 따로 뒀다. 개인은 지수 선물(NQ)을 직접 매매하기 어려워서, 지수의 방향은 QQQ에서 읽고 주문은 레버리지 ETF로 낸다.

차트용도실행
TQQQ 5분봉상승 setup의 진입가·손절·목표 계산TQQQ 매수
QQQ 5분봉하락 setup 신호 추출(인버스 매핑)SQQQ 매수
QQQ 일봉, ^VIX 일봉그날 매매 여부(Daily Bias·변동성 필터)없음 (판단용)

QQQ가 하락 쪽 신호를 주면 그것을 인버스 ETF인 SQQQ를 사는 것으로 뒤집어 실행한다. 봇은 매수만 하는데도 결과적으로는 양방향으로 매매하게 된다.

하루를 여섯 개의 상태로 나눴다

봇이 가장 먼저 알아야 하는 건 지금이 하루 중 어느 국면인가다. 미장봇은 하루를 6개 상태로 나눈 상태머신으로 돌아가고, 메인 루프는 시간을 확인하면서 상태를 한 방향으로만 넘긴다.

하루를 여섯 개의 상태로 나눴다 도식

대기하던 봇은 장 시작 전에 VIX와 방향 필터를 거치고, 첫 15분 동안 ORB 박스를 만든 뒤 신호를 스캔한다. 진입하면 손절과 익절을 지켜보다가 청산하고, 스캔하는 동안 신호가 없으면 그날은 매매 0건으로 마감한다. 대기(WAITING)와 마감(DONE_TODAY)을 뺀 네 상태가 하는 일은 이렇다.

상태핵심 동작
PRE_MARKETVIX 12~30 범위 확인, QQQ 일봉으로 Daily Bias 점수 계산, 공휴일 체크
ORB_FORMING개장 첫 15분(9:30~9:44 ET) 3개 봉으로 ORB 고가·저가 확정
SCANNING돌파와 FVG 감지, 되돌림 진입 시그널 탐색(하루 최대 1건)
POSITION_OPEN손절·익절 모니터링, 11:00 ET 손익분기 이동(BE move)

첫 15분을 두 개의 선으로

ORB(Opening Range)는 개장 직후 첫 3개 5분봉, 즉 9:30, 9:35, 9:40 ET 봉의 최고가와 최저가다. 이 두 값이 그날 모든 판단의 기준선이 된다. 코드는 생각보다 단순하다.

Python
def calculate_orb(bars_5m):
    # 09:30~09:44 ET, 첫 3개 5분봉만 사용
    orb_bars = bars_5m.between_time("09:30", "09:44")
    if len(orb_bars) < 3:
        return None
    orb_high = orb_bars["High"].max()
    orb_low  = orb_bars["Low"].min()
    return OpeningRange(high=orb_high, low=orb_low,
                        range_size=orb_high - orb_low)

다만 개장 초에 변동성이 터지는 날은 ORB 폭이 너무 넓어진다. 손절 폭도 그만큼 커지니, 손절 폭(1R)에 비해 목표가 몇 배인지를 따지는 R:R이 나빠진다. 그래서 20일 평균 일중 변동폭(ADR)과 비교해 ORB가 1.5배를 넘으면 그 종목은 그날 거른다.

Python
def is_orb_too_wide(orb, avg_daily_range, max_ratio=1.5):
    ratio = orb.range_size / avg_daily_range
    return ratio > max_ratio    # 너무 넓으면 그 leg 스킵

가격이 건너뛴 빈 공간

FVG는 5편에 적었듯 연속된 캔들 3개에서 1번 봉과 3번 봉 사이에 겹치지 않고 남은 가격 공간이다. 상승 FVG는 1번 봉 고가가 3번 봉 저가보다 낮을 때, 그러니까 가격이 위로 건너뛰었을 때 성립한다. 함수는 이 조건 하나가 전부다.

Python
def detect_bullish_fvg(candles):     # 정확히 3개 봉
    c1, c3 = candles.iloc[0], candles.iloc[2]
    if c1["High"] < c3["Low"]:        # 겹치지 않는 빈 공간
        return FairValueGap(top=c3["Low"], bottom=c1["High"],
                            size=c3["Low"] - c1["High"])
    return None

하락 FVG는 정확히 거울상이어서 1번 봉 저가가 3번 봉 고가보다 높을 때다. 이렇게 잡아 둔 FVG 박스로 나중에 가격이 되돌아오면 그때가 진입 후보가 된다.

가짜 신호를 걷어낸 strict 조건

코드로 옮기면서 가장 공을 들인 곳이 여기다. 돌파 뒤 어딘가에 FVG가 생겼다는 것만으로 들어가면 신호가 너무 많고 대부분 가짜다. 원래 규칙의 핵심은 FVG가 ORB 라인을 관통해야 한다는 것이어서, 이를 두 조건으로 나눠 넣었다. 하나(S1)는 돌파 봉의 몸통이 ORB 라인을 가로지르는 것이다. 시가는 ORB 고가 아래, 종가는 위여야 하니 Open ≤ ORB high ≤ Close가 된다. 다른 하나(S2)는 FVG 영역이 ORB 라인을 품는 것으로, fvg.bottom ≤ ORB high ≤ fvg.top 이다.

Python
def check_breakout_with_fvg(bars, orb_high, i, strict=False):
    candle = bars.iloc[i]
    # 돌파: ORB high 위 종가 + 양봉
    if not (candle["Close"] > orb_high and candle["Close"] > candle["Open"]):
        return None
    # (S1) 몸통이 ORB 라인을 가로지름
    if strict and not (candle["Open"] <= orb_high <= candle["Close"]):
        return None
    fvg = detect_bullish_fvg(bars.iloc[i-1 : i+2])
    if fvg is None:
        return None
    # (S2) FVG 영역이 ORB 라인을 포함
    if strict and not (fvg.bottom <= orb_high <= fvg.top):
        return None
    return fvg

strict 한 줄의 효과는 컸다. 백테스트에서 가짜 시그널이 약 70% 사라졌다. 신호 수가 줄어드는 대신 남은 신호의 질은 올라가는데, 이 맞바꿈은 연재 뒤쪽에서 다시 문제가 된다.

진입가와 손절, 목표는 어떻게 정할까

ORB와 FVG 조각을 실제 매매 신호로 합치는 곳이 시그널 엔진이다. 스캔 윈도우(9:45~10:55 ET)의 5분봉을 차례로 돌면서, 돌파와 FVG가 확인되면 진입가와 손절, 목표를 신호 객체 하나로 만든다.

Python
def scan_for_signal(bars_5m, orb, symbol, rr_ratio=2.0,
                    strict=True, allowed_killzones=["AM_MACRO"],
                    require_displacement=False, ...):
    # 1. 각 봉이 ORB 돌파 + FVG(strict=ORB 관통) 형성?
    # 2. 진입 = FVG 중간값,  손절 = 직전 봉 저가
    # 3. 목표 = 진입 + (진입-손절) × R:R
    # 4. killzone·displacement·sweep 필터 통과?
    return TradeSignal(entry_price=..., stop_loss=...,
                       take_profit=..., rr_ratio=rr_ratio)  # 없으면 None

신호 객체(TradeSignal)에는 진입가, 손절, 목표 말고도 부분 익절가와 R:R 비율이 담긴다. 진입은 되돌림이 FVG 중간값에 닿는 순간 시장가로 사고, 손절은 직전 봉 저가에 둔다. 목표는 진입 시각에 따라 다르다. 개장 후 첫 40분 안에 들어가면 손절 폭의 3배, 그보다 늦으면 2배로 잡는다.

손절 폭의 1.5배(1.5R)에 닿으면 일부를 먼저 청산하고, 11:00 ET까지 남아 있는 포지션은 손절을 진입가로 올린다. 15:50 ET에는 남은 것을 전부 시장가로 정리해서 포지션을 밤까지 들고 가지 않는다.

여기에 상위 필터를 옵션으로 얹었다. ICT에서는 매매를 받는 시간대를 킬존이라고 부르는데, 킬존 필터는 개장 후 첫 40분에 나온 setup만 받는다. 돌파 봉이 한 방향으로 크게 뻗었는지 보는 displacement 검증도 있다. 직전 고점이나 저점을 살짝 넘겼다 되돌아온 뒤(sweep) 흐름이 바뀌는 신호(CHoCH)가 순서대로 나왔는지도 본다.

함수 인자만 20개가 넘는다. 필터마다 켜고 끌 수 있게 해 두고, 백테스트로 효과를 확인하면서 하나씩 붙여 나갔다.

2026년 4월 7일 SQQQ에서 맞물린 순서

이론만으로는 감이 잘 안 와서, 실거래 로그에서 가장 깔끔했던 사례 하나를 옮긴다. 그날 SQQQ의 ORB는 고가 $77.57, 저가 $76.52로 폭이 $1.05였다. 강한 양봉이 몸통으로 ORB 고가를 뚫고 마감하면서 $77.98에서 $78.13 사이에 FVG가 생겼다. 되돌림이 FVG 중간값 $78.05에 닿았을 때 샀고, 약 8분 뒤 목표가 $79.24에 닿아 손절 폭의 1.78배(+1.78R)를 남기고 익절했다.

필터를 다 켜자 매매가 사라졌다

상위 ICT 필터를 전부 켰더니 신호가 거의 잡히지 않았다. 60일 구간에서 조건이 모두 맞아떨어진 날이 손에 꼽을 정도였다. ORB를 돌파한 뒤 되돌림 없이 한쪽으로 쭉 가 버려서 들어갈 자리가 없는 날이 있었고, 종일 ORB 안에서 횡보해 돌파 자체가 안 나온 날도 있었다. 좋은 setup이 첫 40분이 지난 뒤에 나와 킬존 필터에 막히기도 했고, Daily Bias가 중립이라 그날을 통째로 건너뛰기도 했다.

양보다 질이라는 생각은 그럴듯했지만, 매매 빈도가 이렇게 낮으면 검증할 표본도 그만큼 적다.

이 전략을 어떻게 의심했는지와 결국 왜 접었는지는 다음 두 편에 적었다.

이 글은 자동매매 시스템을 만들며 남긴 개발 기록이고, 특정 종목의 매수나 매도를 권하는 글이 아니다. 수치와 코드는 실제 구현과 실거래 로그에서 가져왔으며, 투자 판단과 책임은 각자에게 있다.

참고한 자료: Casper SMC 유튜브 채널 (@caspersmc)

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다