AlgoLab Blog · 퀀트·시스템 트레이딩 · 2026

체결강도·거래원·프로그램매매로 봇 필터 만들기

퀀트 · 수급 필터 2026-09-23 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약

수급 데이터 세 가지는 매수 신호가 아니라 진입을 막는 게이트로 쓸 때 코드가 단순해지고 검증이 가능해집니다. 한국투자증권 KIS API 기준으로 체결강도는 tday_rltv(inquire_ccnl·volume_power 응답에 포함), 거래원은 inquire_member(seln_mbcr_name1~5·shnu_mbcr_rlim1~5, 상위 5개만), 프로그램매매는 program_trade_by_stock(whol_smtn_ntby_qty) 또는 현재가 응답의 pgtr_ntby_qty 입니다. 키움 REST API 는 각각 ka10040·ka90003 이 대응합니다. 임계값을 최적화해 매수 조건으로 쓰면 과최적화로 끝나고, “셋 중 하나라도 명백히 반대면 이번 진입은 건너뛴다” 로 쓰면 임계값이 둔감해도 됩니다. 종목당 3콜이므로 유니버스 전체가 아니라 진입 직전 종목에만 붙이는 것이 EGW00201 을 피하는 방법입니다.

이 글은 개별 API 스펙 글이 아닙니다. 필드 하나하나는 KIS 체결조회 FHPST01060000, 키움 거래원 조회 ka10040, 키움 프로그램매매 API 8종에 이미 정리해 두었습니다. 여기서는 그 셋을 어떻게 하나의 판단으로 묶느냐 만 다룹니다.

목차

  1. 신호로 쓰면 지고, 필터로 쓰면 버틴다
  2. 체결강도 tday_rltv — 무엇이고 언제 못 믿나
  3. 거래원 inquire_member — 5개만 온다
  4. 프로그램매매 — 장중은 가집계다
  5. 셋을 묶는 게이트 코드
  6. 호출 수 계산 — 어디에 붙일 것인가
  7. 이 필터가 도움이 되는지 확인하는 법
  8. 남는 한계

1. 신호로 쓰면 지고, 필터로 쓰면 버틴다

수급 데이터를 처음 붙이는 사람은 거의 예외 없이 이렇게 씁니다.

# 흔한 첫 시도 — 이렇게 쓰면 대부분 표본외에서 무너진다
if tday_rltv > 130 and pgtr_ntby_qty > 0:
    buy(code)

문제는 130 이라는 숫자가 어디서 왔느냐 입니다. 120·130·150 을 각각 백테스트해 제일 좋은 하나를 골랐다면, 그 값은 시장의 성질이 아니라 과거 표본에 맞춘 값입니다. 조건을 두 개 걸면 격자가 두 배가 되고, 세 개면 세 배가 됩니다. 수백 번 돌려 최고만 보여 주는 착시에서 다룬 데이터 스누핑이 정확히 이 모양으로 들어옵니다.

방향을 뒤집으면 이 문제가 상당 부분 사라집니다.

# 필터(게이트)로 쓰기 — 진입 조건은 원래 전략이 정한다
if strategy_says_buy(code):            # 이동평균·돌파 등 원래 로직
    ok, why = supply_gate(code)        # 수급은 '막을 이유'만 본다
    if ok:
        buy(code)
    else:
        log.info("skip %s: %s", code, why)

차이는 임계값의 민감도입니다. 신호로 쓰면 130 이냐 135 냐에 따라 거래 자체가 달라지지만, 필터로 쓰면 “명백히 반대인 경우” 만 걸러 내므로 80 이든 90 이든 결과가 급변하지 않습니다. 임계값을 조금 흔들어도 결과가 크게 안 변한다는 성질 자체가, 그 파라미터가 과거에 과하게 맞춰지지 않았다는 신호입니다.

이 글에서 쓰는 ‘명백히 반대’ 의 정의 — ⑴ 체결강도가 100 을 크게 밑돌아 매도 체결이 계속 이기는 중이거나, ⑵ 상위 매도 창구 비중이 매수 창구를 크게 앞서거나, ⑶ 프로그램 순매수가 뚜렷한 음수인 경우입니다. 숫자는 뒤에서 정하되 세 개 중 몇 개가 걸리면 막을지를 먼저 정하는 편이 낫습니다.

2. 체결강도 tday_rltv — 무엇이고 언제 못 믿나

체결강도는 매수 체결량 대비 매도 체결량의 비율을 100 기준으로 환산한 값입니다. 별도 API 를 찾을 필요가 없습니다. 한국투자증권 공식 저장소의 컬럼 매핑 기준으로 tday_rltv당일 체결강도 이고, 아래 세 API 응답에 같은 이름으로 들어 있습니다.

API함수명같이 오는 것
주식현재가 체결inquire_ccnlstck_cntg_hour, cntg_vol, prdy_ctrt
당일시간대별체결inquire_time_itemconclusionaskp, bidp, cnqn, acml_vol
국내주식 체결강도 상위volume_powerdata_rank, seln_cnqn_smtn, shnu_cnqn_smtn

실무에서 중요한 것은 세 번째 입니다. volume_power 는 순위형이라 1콜로 여러 종목의 체결강도를 한 번에 받습니다. 종목마다 inquire_ccnl 을 부르는 것과 호출 수가 완전히 다릅니다.

{
  "output": [
    {
      "stck_shrn_iscd": "005930",
      "data_rank": "1",
      "hts_kor_isnm": "...",
      "stck_prpr": "71800",
      "acml_vol": "12345678",
      "tday_rltv": "142.35",        // 당일 체결강도
      "seln_cnqn_smtn": "5120340",  // 매도 체결량 합계
      "shnu_cnqn_smtn": "7289110"   // 매수 체결량 합계
    }
  ]
}

체결강도를 못 믿어야 하는 두 구간이 있습니다.장 시작 직후 — 분모가 되는 매도 체결량 자체가 몇 건뿐이라 값이 200 을 넘었다 80 으로 내려앉습니다. 표본이 없는데 비율만 계산된 상태입니다. ⑵ 거래량이 적은 종목 — 한 건의 큰 체결이 지표를 통째로 뒤집습니다. 그래서 체결강도는 acml_vol(누적 거래량) 하한과 반드시 같이 써야 하고, 개장 후 일정 시간이 지나기 전에는 아예 판단하지 않는 편이 안전합니다.

3. 거래원 inquire_member — 5개만 온다

KIS 의 주식현재가 회원사(inquire_member)는 매도·매수 각각 상위 5개 창구를 줍니다. 필드가 번호 붙은 평면 구조라 처음 보면 당황스럽습니다.

필드 패턴개수
seln_mbcr_no1~5매도 회원사 번호5
seln_mbcr_name1~5매도 회원사 명5
total_seln_qty1~5총 매도 수량5
seln_mbcr_rlim1~5매도 회원사 비중5
seln_qty_icdc1~5매도 수량 증감5
shnu_mbcr_name1~5매수 회원사 명5
total_shnu_qty1~5총 매수 수량5
shnu_mbcr_rlim1~5매수 회원사 비중5
def member_balance(o):
    """inquire_member output -> (매수비중합, 매도비중합)"""
    def fsum(prefix):
        t = 0.0
        for i in range(1, 6):
            v = o.get(f"{prefix}{i}", "")
            try:
                t += float(v)
            except (TypeError, ValueError):
                pass          # 창구가 5개 미만이면 빈 문자열이 온다
        return t
    return fsum("shnu_mbcr_rlim"), fsum("seln_mbcr_rlim")

상위 5개의 합은 전체가 아닙니다. 6위 이하 창구는 응답에 없습니다. 따라서 “매수 비중 합 55% vs 매도 비중 합 48%” 같은 비교는 보이는 범위 안에서의 비교일 뿐입니다. 또 창구 이름은 주문을 낸 증권사이지 실제 매수 주체가 아닙니다. 외국계 창구 합계로 외국인 수급을 추정하는 관행이 있지만 이는 어디까지나 추정치이며, 정확한 투자자별 수급은 외국인·기관 순매수 FHKST01010900 쪽을 봐야 합니다. 키움 REST API 는 ka10040 이 같은 역할입니다.

4. 프로그램매매 — 장중은 가집계다

프로그램매매는 경로가 두 개입니다.

# 추이가 필요 없으면 현재가 1콜로 끝난다
o = price_output                       # inquire-price 의 output
pgtr = int(o.get("pgtr_ntby_qty") or 0)   # 프로그램 순매수 수량
frgn = int(o.get("frgn_ntby_qty") or 0)   # 외국인 순매수 수량

장중 수치는 확정치가 아닙니다. 투자자별·프로그램매매 집계는 장중에 가집계로 내려오고 장 종료 후 정정됩니다. 어제 장중에 본 값과 오늘 조회한 어제 값이 다를 수 있다는 뜻입니다. 백테스트를 장 마감 데이터로 만들고 실전은 장중 가집계로 돌리면, 실전에서는 알 수 없었던 정보를 백테스트가 쓴 셈이 됩니다 — 룩어헤드 편향의 교과서적인 형태입니다. 필터로 쓸 때는 부호와 대략적 크기만 보고, 정밀한 임계값을 걸지 마십시오. 키움 REST API 쪽 대응은 ka90003 이며 시장 코드 체계가 별도입니다.

5. 셋을 묶는 게이트 코드

이제 하나로 묶습니다. 설계 원칙은 셋입니다 — 기본값은 통과, 데이터가 없으면 통과(수급 조회 실패로 전략이 멈추면 안 됩니다), 막은 이유를 남긴다.

from dataclasses import dataclass

@dataclass
class SupplySnapshot:
    tday_rltv: float | None      # 당일 체결강도
    acml_vol:  int               # 누적 거래량
    buy_rlim:  float | None      # 매수 상위5 비중 합
    sell_rlim: float | None      # 매도 상위5 비중 합
    pgtr_ntby: int | None        # 프로그램 순매수 수량

MIN_VOL      = 50_000   # 이보다 적으면 체결강도를 믿지 않는다
RLTV_FLOOR   = 80.0     # 명백히 매도 우위로 볼 선
RLIM_GAP     = 10.0     # 매도 비중이 매수보다 이만큼 크면 반대
BLOCK_AT     = 2        # 반대 신호 2개 이상이면 진입 취소

def supply_gate(s: SupplySnapshot):
    reasons = []

    if s.tday_rltv is not None and s.acml_vol >= MIN_VOL:
        if s.tday_rltv < RLTV_FLOOR:
            reasons.append(f"체결강도 {s.tday_rltv:.1f}")

    if s.buy_rlim is not None and s.sell_rlim is not None:
        if s.sell_rlim - s.buy_rlim >= RLIM_GAP:
            reasons.append(f"창구 매도우위 {s.sell_rlim - s.buy_rlim:.1f}p")

    if s.pgtr_ntby is not None and s.pgtr_ntby < 0:
        reasons.append(f"프로그램 순매도 {s.pgtr_ntby:,}")

    if len(reasons) >= BLOCK_AT:
        return False, " / ".join(reasons)
    return True, " / ".join(reasons) or "clear"

눈여겨볼 곳은 BLOCK_AT = 2 입니다. 하나만 걸려도 막게 하면 필터가 너무 자주 발동해 거래가 거의 없어지고, 셋 다 요구하면 사실상 아무것도 안 막습니다. “셋 중 둘” 은 개별 임계값보다 훨씬 둔감한 파라미터라, 여기부터 정하고 임계값은 느슨하게 두는 편이 낫습니다.

전략 진입 신호 돌파 · 이동평균 등 tday_rltv 체결강도 < 80 ? inquire_member 매도 창구 우위 ? pgtr_ntby_qty 프로그램 순매도 ? 2개 이상 반대? 진입 취소 주문 전송
전략이 먼저 결정하고, 수급은 막을 이유만 본다 — 세 개 중 둘이 반대면 취소

6. 호출 수 계산 — 어디에 붙일 것인가

여기서 설계가 갈립니다. 종목 하나에 체결·거래원·프로그램매매를 다 부르면 3콜 입니다.

붙이는 위치대상콜 수판정
유니버스 전체 스캔300종목900콜EGW00201
신호 후보 전체30종목90콜△ 주기에 따라 위험
진입 직전 종목만분당 3~4건9~12콜✅ 안전
체결강도만 순위로volume_power1콜✅ 사전 스크리닝용

결론은 단순합니다. 체결강도는 volume_power 로 싸게 훑고, 거래원과 프로그램매매는 실제로 주문을 낼 종목에만 붙입니다. 프로그램 순매수는 어차피 지정가를 정하려고 부르는 inquire-price 응답의 pgtr_ntby_qty 로 받으면 추가 호출이 아예 없습니다. 초당 한도와 재시도 설계는 API 호출 제한 설계에 따로 정리해 두었습니다.

수급 게이트 앞에 유니버스 게이트를 먼저 두십시오. 관리종목·투자경고·정리매매 종목은 체결 방식 자체가 달라 수급 지표를 계산할 이유가 없습니다. mang_issu_cls_code 로 관리종목 거르기를 먼저 적용하면 수급 3종을 부를 종목 수가 줄어 호출 수도 같이 줄어듭니다.

7. 이 필터가 도움이 되는지 확인하는 법

필터를 붙였으면 붙이기 전과 나란히 비교해야 합니다. 같은 전략, 같은 기간, 같은 비용 가정에서 필터만 껐다 켭니다. 볼 것은 수익률 하나가 아닙니다.

지표왜 보나읽는 법
차단 건수필터가 일을 했는지전체 진입의 1% 미만이면 사실상 없는 필터
거래 횟수절반 이상 줄었다면필터가 아니라 전략을 바꾼 것
MDD필터의 본래 목적최대낙폭이 줄지 않으면 명분이 약하다
샤프지수거래당 효율거래가 줄면 표본도 줄어 값이 불안정
res_off = backtest(strategy, use_gate=False)
res_on  = backtest(strategy, use_gate=True)

for k in ("trades", "mdd", "sharpe"):
    print(f"{k:8} {res_off[k]:>10.3f} -> {res_on[k]:>10.3f}")
print("blocked:", res_on["blocked"], "/", res_off["trades"])

계산은 pandas 로 직접 해도 되고 quantstats 같은 라이브러리를 써도 됩니다 — 다만 지표 계산에도 함정이 있어서 샤프지수·MDD 계산 함정을 한 번 보고 쓰는 편이 안전합니다.

두 결과의 차이를 성과 개선으로 읽지 마십시오. 필터를 켠 쪽이 더 좋아 보이는 것은 임계값을 그 구간에 맞춰 골랐기 때문일 수 있습니다. 최소한 워크포워드처럼 파라미터를 정한 구간과 평가하는 구간을 분리해 같은 방향이 나오는지 확인해야 합니다. 그렇게 해도 과거 성과가 미래를 보장하지 않습니다. 이 글의 지표·개념은 학술적으로 통용되는 정의를 따른 것이며, 특정 수익률이나 시장 방향을 예측하지 않습니다.

8. 남는 한계

필터 설계부터 막히셨다면

전략은 있는데 “언제 안 살 것인가” 를 코드로 옮기는 데서 멈추는 경우가 많습니다. 24시간 빠른 답변 가능합니다.

자동매매 제작 상담하기

알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 본 글은 특정 종목의 매매를 권유하거나 수익을 보장하지 않습니다. 본문의 지표와 임계값은 설명을 위한 예시이고 과거 성과가 미래 수익을 보장하지 않습니다. 소프트웨어 제작·기술 정보 제공 목적의 글이며, 투자 판단과 그 결과에 대한 책임은 투자자 본인에게 있습니다. API 스펙은 발행 시점 기준이며 변경될 수 있으므로 각 증권사 공식 문서를 확인하십시오.