시리즈 마지막. 백테스트와 라이브 매매를 한 결로 묶어준 두 가지 작은 디테일 — Hysteresis 로 청산 룰을 자동 도출하는 방식, 그리고 자본이 변해도 매번 균등 분배되도록 만든 sentinel 패턴.
Hysteresis — 진입 stricter / 청산 looser
Mean reversion 전략의 표준 패턴 중 하나. 진입은 1.0σ에서, 청산은 0.5σ에서 — 같은 신호를 비대칭 임계값으로 다루는 것. 신호가 임계값 근처에서 진동할 때 발생하는 whipsaw 매매를 줄이는 게 목적이다.
코드로 표현하면 진입 룰의 “거울”은 부등호만 뒤집은 게 아니라, 임계값을 어느 정도 안쪽으로 이동시킨 것이어야 한다.
- 진입:
RSI > 65(과매수 가속) - 청산:
RSI < 50(과매수 해소) — 임계값 50까지 풀어서
이걸 사용자가 매번 직접 짜는 건 일이다. 그래서 진입 룰만 만들어 두면 자동으로 buffer 둔 청산 룰을 생성하는 모듈을 만들었다.
# service/live/hysteresis.py
def _derive_one(factor, op, value):
# RSI — 50을 중심으로 15 buffer
if factor == "rsi_14":
if op in (">", ">="):
exit_value = max(50.0, value - 15.0)
return {"factor": factor, "op": "<", "value": round(exit_value, 4)}
if op in ("<", "<="):
exit_value = min(50.0, value + 15.0)
return {"factor": factor, "op": ">", "value": round(exit_value, 4)}
# price_vs_smaN — 0(=sma선) 기준으로 추세 깨짐 판정
if factor.startswith("price_vs_sma"):
if op in (">", ">="):
return {"factor": factor, "op": "<", "value": 0.0}
if op in ("<", "<="):
return {"factor": factor, "op": ">", "value": 0.0}
# vol_ratio_20d → 평균(1.0)으로 정상화 판정
# return_5d → 0(수익률 마이너스 전환)
# sma20_vs_sma50 → 0(이평선 교차)
...
요점은 buffer가 factor의 의미와 함께 정의된다는 점이다.
- RSI: 50 중심 ±15
price_vs_smaN: 0 (= 이평선 위/아래) 기준vol_ratio_20d: 1.0 (= 평균 거래량) 기준return_5d: 0 (= 수익률 마이너스 전환) 기준sma20_vs_sma50: 0 (= 골든/데드 크로스)
각 factor에 대해 “이 신호가 해소됐다고 부를 만한 자연스러운 경계”를 정해두고, 진입 룰의 임계값에서 그 경계 쪽으로 이동시킨다. RSI는 50 중심이라 RSI > 65 진입이면 청산은 RSI < max(50, 65-15) = 50. 진입이 75라면 청산은 RSI < 60. buffer 폭이 진입의 강도에 비례한다.
청산 룰은 OR 결합으로 평가한다 — 진입 절 하나라도 풀리면 청산.
def evaluate_signal_exit(factor_row, clauses):
for c in clauses:
x = factor_row.get(c["factor"])
# < <= > >= = != 평가, 하나라도 맞으면 True
...
이게 라이브 executor의 _evaluate_exits 안에서 stop_loss·take_profit·trailing_stop·time_exit와 OR로 묶인다. signal_exit은 백테스트의 진입 룰이 더 이상 만족되지 않는 시점을 그대로 청산 시점으로 매핑하는 거라, 백테스트와 라이브 동작이 같은 결로 흐른다.
사용자가 청산 룰을 직접 작성한 경우는 그걸 우선하고, 비어있을 때만 hysteresis 가 자동 도출한 룰을 쓴다.
자본 균등 분배 — position_size_krw <= 0 sentinel
라이브 전략에는 position_size_krw 필드가 있다. 종목당 매수 금액을 고정하는 옵션 — 1,000,000으로 박아두면 모든 신규 매수가 100만원짜리.
문제: 자본이 변하는 환경에선 이게 잘 안 맞는다.
- 시작 자본 1,000만원, 10 슬롯 → 종목당 100만원 OK
- 손실로 800만원 됐을 때 → 여전히 종목당 100만원이면 8개 슬롯만 채워지고 2개는 비어버림
- 더 작은 사이즈로 박아뒀다면 너무 보수적
해법은 sentinel: position_size_krw <= 0일 때 현재 잔고 ÷ 빈 슬롯으로 자동 계산.
# service/live/executor.py
fixed_size = int(strategy.get("position_size_krw") or 0)
equal_weight = fixed_size <= 0
auto_size = int(cash // max(open_slots, 1)) if equal_weight else 0
if equal_weight:
print(f"💸 자본 균등 분배 — 잔고 ₩{cash:,} ÷ 슬롯 {open_slots} = 종목당 ₩{auto_size:,}")
for c in selected:
size_krw = auto_size if equal_weight else fixed_size
qty = int(size_krw // price)
if qty <= 0:
continue # 1주도 못 사면 다음 후보가 같은 size 로 시도
cost = qty * price
if cost > cash:
continue # 잔고 부족분만 자연스럽게 스킵
# 매수
매 라운드마다 잔고 / 빈 슬롯을 다시 계산한다. 8개 슬롯, 잔고 ₩800만원이면 종목당 100만원. 자본이 늘면 자동으로 비례해 늘고, 줄면 자동으로 줄어든다.
여기에 한 가지 디테일: 1주도 못 사면 다음 후보로 넘어가서 같은 size 로 시도. 가격이 너무 비싼 종목 (예: ₩500,000짜리 주식이 100만원 슬롯에 안 맞음) 만 자연스럽게 스킵된다. 잔고 부족분이 슬롯 끝까지 흘러가면 그 슬롯은 빈 채로 남고, 다음 라운드에 다시 시도한다.
KIS의 실제 매수 가능 예수금은 D+2 정산금(prvs_rcdl_excc_amt)이라, cash 변수에 이걸 사용한다. dnca_tot_amt(잔고 총액)은 정산 전 금액이라 매수해도 안 줄어들어 자동 분배 계산이 망가진다.
회고
이 두 디테일은 작은데, 시리즈를 닫는 자리에 두는 이유가 있다.
백테스트와 라이브 사이의 연결성은 이런 작은 결정들로 만들어진다.
Hysteresis 청산 룰은 진입 룰의 명시적 거울이라, 백테스트의 signal exit과 라이브의 signal exit이 같은 코드를 통과한다. 자본 균등 분배는 백테스트의 per_slot_cash = cash / empty_slots 와 라이브의 auto_size = cash // open_slots 가 같은 식이라, 시뮬레이션 상의 자본 변화가 실제 매매에서도 동일하게 재현된다.
이 일관성이 깨지면 backtest 결과는 라이브의 현실과 점점 멀어지고, 결국 “백테스트는 잘 됐는데 실전은 왜 다르지?” 의 가장 큰 원인이 된다. 둘을 한 결로 묶는 건 항상 비싸게 친 디테일이었다.
시리즈 마무리
5편을 거쳐 만든 게 v0.1.0 이다.
- #1 — Monorepo · Docker Compose로 자가호스팅 만들기
- #2 — TimescaleDB와 팩터 precompute로 백테스트 만들기
- #3 — Grid Search로 검증된 룰만 추천하는 자동 탐색
- #4 — KIS Open API로 라이브 자동매매: 토큰·rate limit·동시호가의 함정
- #5 — (이 글)
다음 단계로 더 나아가면 — multi-asset (선물·옵션·해외 ETF) / 다중 활성 전략 / 알림 (Telegram·Slack) / 백테스트 결과 vs 라이브 성과 정합성 모니터링 — 같은 게 남는다. 다만 v0.1.0 자체는 한 사람의 자가호스팅 도구로 충분히 동작하는 상태고, 한동안은 돌리면서 데이터를 쌓는 단계로 넘어갈 예정이다.
읽어주셔서 감사합니다.