Forge Fate
퀀트·데이터 연구

첫 백테스트는 수익률보다 수량부터: 매수 후 보유와 모멘텀의 한 구간 회계

약 7분

근거와 검증 범위 — 합성 입력의 회계 예제

가상자산 SYN_A/B/C의 한 보유기간을 손계산과 코드로 설명합니다. 실제 ETF 가격·시장 달력·최종 테스트 자료는 확보하지 않았으며, 제시한 수치는 실제 수익률이나 전략 우위의 증거가 아닙니다.

3편에서 정한 시간 계약은 그대로 유지한다. 신호는 신호일 종가까지의 정보로 확정하고, 주문은 다음 거래일 종가에 실행하며, 실행 이후의 가격 변화만 수익에 반영한다. 이번에는 이 시간 순서 위에서 “무엇을 얼마나 사고, 현금과 평가액을 어떻게 기록하는가”를 검증한다.

프로젝트의 미국 ETF 연구 후보는 SPY·IEF·GLD이지만, 실제 CSV, 시장 달력, 20062025 가격 자료는 아직 확보하지 않았다. 20212025 최종 테스트 자료도 확보하거나 열람하지 않았다. 따라서 아래의 SYN_A·SYN_B·SYN_C는 실제 ETF와 연결되지 않는 가상자산이며, 한 보유기간의 손계산을 위한 합성 입력이다. 이 글은 시장 백테스트나 전략 우위를 검증하는 글이 아니라, 앞으로 실제 데이터를 연결하기 전에 공통 회계 규칙을 고정하는 구현 설명이다.

핵심은 단순하다. 두 전략에 같은 시작자산, 같은 실행가격, 같은 종료 평가가격을 적용하고, 달라지는 것은 배분 규칙과 그에 따른 수량뿐이게 한다. 그래야 종료가치 차이가 코드의 시간 오류나 숨은 현금 처리 때문이 아니라, 명시한 보유 규칙에서 왔는지 확인할 수 있다.

한 보유기간의 공통 입력

이번 예시의 12개월 전 참조가격은 [80, 120, 100], 신호일 종가는 모두 100이다. 각 자산은 자기 자신의 과거 가격과 현재 신호일 가격을 비교한다. 이런 관점은 자산 간 상대순위를 매기는 방식과 구분되는 시계열 모멘텀의 기본 개념과 닿아 있다. [S1]

항목SYN_ASYN_BSYN_C
12개월 전 참조가격80120100
신호일 종가100100100
12개월 신호1/4-1/60
다음 거래일 실행종가100100100
다음 리밸런싱 직전 평가가격12090120

신호 계산은 다음과 같다.

  • SYN_A: 100 / 80 - 1 = 1/4
  • SYN_B: 100 / 120 - 1 = -1/6
  • SYN_C: 100 / 100 - 1 = 0

여기서 신호일 종가 100은 주문 체결가격이 아니다. 신호가 확정된 뒤, 다음 거래일 실행종가 [100, 100, 100]에서 최초 주문을 낸 것으로 계산한다. 그 뒤 다음 리밸런싱 직전 가격 [120, 90, 120]으로 보유분을 평가한다.

초기자산은 1000, 거래비용은 0, 현금수익은 0으로 둔다. 소수 수량도 허용한다. 현실의 ETF 주문 단위나 체결 제약을 재현하려는 설정이 아니라, 수량과 가치의 관계를 정확히 보기 위한 최소 입력이다.

Fraction을 쓰면 1/4, -1/6, 10/3, 4/11처럼 유리수로 나오는 값을 부동소수점 반올림 없이 표현할 수 있다. [S2] 이 선택은 계산 결과를 사람이 손으로 대조하기 쉽게 만들지만, 코드 실행이나 시장 현실성을 보증하지는 않는다.

이번 모멘텀 규칙: 양수만 선택하고, 없으면 현금

이번 규칙은 원논문을 그대로 재현하지 않는다. 시계열 모멘텀 연구는 각 증권 자신의 과거 수익률에 주목하지만, 해당 연구의 실증 대상과 이번 합성 예시의 자산·롱온리 규칙·현금 대기 규칙은 다르다. [S1]

이 프로젝트에서 쓸 교육용 규칙은 다음과 같다.

  1. 12개월 신호가 엄격히 양수인 자산만 선택한다.
  2. 선택 자산이 있으면 선택된 자산을 동일가중한다.
  3. 선택 자산이 없으면 전부 현금으로 둔다.
  4. 신호일에는 체결하지 않고 다음 거래일 실행가로 수량을 계산한다.
  5. 최초 진입 뒤 매수 후 보유 전략의 비중을 매월 다시 동일가중으로 맞추지 않는다.

이번 신호 [1/4, -1/6, 0]에서 엄격히 양수인 것은 SYN_A뿐이다. 따라서 모멘텀 비중은 [1, 0, 0]이다. SYN_C의 신호는 0이므로 선택하지 않는다. “0 이상”이 아니라 “0 초과”라는 경계를 코드와 문서에서 같은 표현으로 유지해야 한다.

매수 후 보유는 수량을 고정한다

매수 후 보유는 초기자산 1000을 세 자산에 동일하게 나눠 최초 한 번 진입한다. 자산별 배정액은 1000 / 3이고, 실행가가 모두 100이므로 최초 수량은 각각 다음과 같다.

text
(1000 / 3) / 100 = 10 / 3

따라서 최초 보유수량은 SYN_A, SYN_B, SYN_C 각각 10/3이다. 이후 가격이 달라져도 이 수량은 바뀌지 않는다. 종료 시점의 자산별 가치는 다음과 같다.

text
SYN_A: (10 / 3) × 120 = 400SYN_B: (10 / 3) ×  90 = 300SYN_C: (10 / 3) × 120 = 400

종료가치는 400 + 300 + 400 = 1100이다. 시작자산 대비 10% 증가지만, 이것은 사람이 정한 합성가격을 대입한 산술 결과일 뿐이다.

중요한 점은 종료비중이 처음의 3분의 1씩이 아니라는 것이다. 종료비중은 각각 4/11, 3/11, 4/11이 된다. 가격이 오른 A와 C의 비중은 커지고, 가격이 내린 B의 비중은 작아진다. 이는 오류가 아니라 수량을 고정한 결과다. 매월 다시 3분의 1씩 맞추면 그것은 매수 후 보유가 아니라 별도의 정기 리밸런싱 전략이다.

모멘텀도 같은 장부로 평가한다

모멘텀은 양수 신호를 받은 SYN_A만 선택한다. 초기자산 전액 1000을 실행가 100에 사용하므로 수량은 10이다. 종료가격 120에서 평가하면 종료가치는 1200이다.

전략최초 수량종료가치종료 비중
매수 후 보유A=10/3, B=10/3, C=10/31100A=4/11, B=3/11, C=4/11
양수 신호 모멘텀A=10, B=0, C=01200A=1, B=0, C=0
전부 현금 검사A=0, B=0, C=0, 현금=10001000현금=1

같은 가격 경로에서 매수 후 보유는 10%, 모멘텀은 20%가 나온다. 그러나 이 숫자를 모멘텀의 우위, 실제 ETF 수익률, 투자 가능성의 증거로 읽으면 안 된다. 종료가격은 사람이 회계 검사용으로 정했으며, 한 구간의 보기 좋은 결과는 전략 탐색과 백테스트에서 발생하는 과적합 위험을 줄여 주지 않는다. [S3]

이 예시의 목적은 “A만 샀으니 더 좋았다”가 아니다. 두 전략이 같은 실행시점과 같은 평가시점을 공유하면서도, 각자 정의된 수량 규칙을 정확히 지켰는지 확인하는 데 있다.

손계산과 대조하는 핵심 코드

아래 코드는 수량·현금·평가액을 하나의 함수로 계산한다. signals는 신호일에서 확정하고, execution_close는 다음 거래일 가격으로만 수량을 계산한다. end_close는 그 뒤 보유분을 평가하는 가격이다.

python
from fractions import Fraction as F# Keep all inputs exact for hand-checkable accounting.reference = [F(80), F(120), F(100)]signal_close = [F(100), F(100), F(100)]execution_close = [F(100), F(100), F(100)]end_close = [F(120), F(90), F(120)]initial_cash = F(1000)# Confirm the signal before the next-session execution.signals = [now / past - 1 for past, now in zip(reference, signal_close)]# Select strictly positive signals and equal-weight selections.selected = [signal > 0 for signal in signals]selected_count = sum(selected)momentum_weights = [    F(1, selected_count) if chosen else F(0)    for chosen in selected] if selected_count else [F(0), F(0), F(0)]# Buy and hold enters equally once, then keeps quantities fixed.buyhold_qty = [initial_cash / 3 / price for price in execution_close]# Momentum calculates quantities at the next-session execution price.momentum_qty = [    initial_cash * weight / price    for weight, price in zip(momentum_weights, execution_close)]def value(quantities, prices, cash=F(0)):    return cash + sum(        qty * price for qty, price in zip(quantities, prices)    )# Verify signals and initial accounting.assert signals == [F(1, 4), F(-1, 6), F(0)]assert momentum_weights == [F(1), F(0), F(0)]assert value(buyhold_qty, execution_close) == initial_cashassert buyhold_qty == [F(10, 3), F(10, 3), F(10, 3)]# Verify fixed quantities and ending valuation.assert buyhold_qty == [F(10, 3), F(10, 3), F(10, 3)]assert value(buyhold_qty, end_close) == F(1100)assert value(momentum_qty, end_close) == F(1200)# Verify cash-only valuation when cash return is zero.cash_only_qty = [F(0), F(0), F(0)]assert value(cash_only_qty, end_close, cash=initial_cash) == initial_cash

검사는 네 가지 질문에 답한다. 최초 매수 직후 매수 후 보유의 평가액이 정확히 1000인가, 매수 후 보유 수량이 종료까지 고정되는가, 종료 평가액이 각각 1100과 1200인가, 전부 현금이면 가격 변동과 무관하게 평가액이 1000으로 유지되는가다.

제공된 실행 기록에 따르면, 2026-09-16 로컬 python3에서 표준 fractions.Fraction으로 이 assert들이 모두 PASS했다. 이는 제공 기록에 근거한 결과이며, 이 글에서 독립 재실행한 결과는 아니다. 또한 전부 현금의 평가액 불변을 검사했다고 해서, 비양수 신호를 만드는 모든 분기나 월별 실행 흐름 전체를 자동 검증했다는 뜻도 아니다.

아직 검증하지 않은 범위

이 한 구간은 중간 12개월의 일별 시계열을 만들지 않는다. 휴장일 처리, 월말 탐지, 날짜 정렬, 누락값 처리도 구현하지 않았다. 실제 CSV가 없으므로 SPY·IEF·GLD의 실제 수익률, 배당·분할 같은 기업행사, 비용·세금, 스프레드, 시장충격, 주문과 체결 가능성 역시 검증 대상이 아니다.

이 합성 한 구간은 성과평가용 결과를 만들기 위한 입력이 아니라 수량·현금·평가액의 회계 검사용이다. 따라서 CAGR, Sharpe, 최대낙폭은 제시하지 않는다.

다음 단계는 더 큰 성과표가 아니다. 먼저 SYN_A/B/C 입력을 손으로 계산해 수량, 현금, 종료가치가 코드와 같은지 확인한다. 실제 데이터가 확보되면 거래일 기준 정렬, 신호일과 실행일의 매핑, 월별 스케줄, 결측 처리, 비용을 차례로 같은 회계 장부에 연결해야 한다. 5편에서는 비용과 위험을 다루겠지만, 실제 데이터·거래일·체결 가정이 확보되기 전에는 실전 성과나 거래 가능성을 주장할 수 없다.

이 글은 교육·연구 목적의 계산 검증이며, 특정 ETF의 매수·매도 권유가 아니다.

Sources

오류 제보·의견 보내기

글 제목과 주소가 포함된 메일 초안을 엽니다. 내용과 받는 사람을 확인한 뒤 보내주세요.

받는 사람: [email protected]

메일 앱에서 작성

메일 앱이 열리지 않으면 아래 내용을 복사해 평소 쓰는 이메일에 붙여 넣으세요.

문의 안내