shift(1)만으로는 부족하다: 신호·체결·레이블의 시간 계약
근거와 검증 범위 — 합성 단위테스트·제공된 실행 기록
본문의 PASS는 프로젝트가 제공한 Python 3.14.5·unittest의 네 가지 합성 검사 기록입니다. 독립 재실행 결과나 전체 시스템의 무누수 증명이 아니며, 실제 가격·주문·체결 데이터 검증은 남아 있습니다.
2편의 품질 검사는 받은 가격 파일이 구조적으로 연구에 쓸 만한지 묻는 단계였다. 하지만 날짜 중복과 결측을 정리해도, 오늘의 의사결정에 내일의 정보가 섞이지 않았는지는 별도로 확인해야 한다.
이번 글은 SPY·IEF·GLD를 후보로 둔 개인 연구 프로젝트에서 실제 CSV를 받기 전에 만드는 합성 단위테스트다. 실제 가격 파일, 주문·체결 기록, 거래일 달력, 완성된 백테스트는 아직 없다. 따라서 여기의 결과는 ETF 성과나 전략의 유효성을 뜻하지 않으며, 시간 규칙을 코드로 고정하는 교육·연구용 검사다.
시간은 날짜 하나가 아니라 여러 계약이다
금융 시계열에서 미래정보 사용은 특정 시점의 모의 의사결정에 그 시점 이후의 정보가 들어갈 때 발생한다. 특성과 레이블의 한 칸 어긋남, 전체 기간으로 계산한 전처리 통계, 실제 공개 시각보다 이른 정보 귀속은 서로 다른 누수 경로다. [S1]
이 프로젝트는 1편에서 정한 다음 거래일 종가 실행 규칙을 유지한다. t일 종가까지 관측한 뒤 신호를 확정하고, t+1일 종가에 주문을 실행한 것으로 가정한다. 그 포지션의 첫 종가수익은 t+1에서 t+2로 끝나는 구간부터 귀속한다.
| 시점 | 수행 내용 | 아직 사용할 수 없는 정보 |
|---|---|---|
t 종가 후 | 관측 가능한 특성으로 신호 확정 | t+1 이후 가격, 수익, 레이블 |
t+1 종가 | 주문 실행으로 가정 | t+2 이후 수익 |
t+2 종가 | 실행 뒤 첫 종가수익 구간 종료 | 그 수익을 계산한 결과를 t 신호에 반영한 값 |
| 레이블 종료일 | 학습용 결과값 확정 | 신호일 뒤에 끝나는 레이블을 과거 학습행으로 귀속한 값 |
shift(1)은 배열의 한 칸 정렬 오류 하나를 고칠 수 있다. 그러나 그것만으로 신호 확정 시각, 다음 거래일 실행, 수익 귀속 시작점, 휴장일 처리, 레이블 관측 구간까지 자동으로 정의되지는 않는다. 이는 누수 유형을 이 연구의 실행 규칙에 적용한 해석이다. [S1]
핵심은 “신호 날짜”만 저장하지 않는 데 있다. 각 행에서 적어도 신호 확정 시각, 주문 실행 시각, 수익 귀속 구간, 레이블 종료 시각, 특성 이용 가능 시각을 구분해야 한다.
실제 데이터보다 먼저 만드는 네 가지 검사
프로젝트 제공 확인 기록에 따르면, 2026-09-15에 로컬 Python 3.14.5와 표준 unittest로 아래 네 합성 검사가 PASS했다. 이는 독립 재실행 결과도, 전체 시스템의 무누수 증명도 아니다. Python 3.14.5는 공식 배포된 유지보수 릴리스이며 [S2], 표준 unittest는 예상한 예외를 확인하는 assertRaises()를 제공한다. [S3]
import unittestfrom datetime import datedef make_signal(prices): # Use only the current and prior price. return [0] + [int(prices[i] > prices[i - 1]) for i in range(1, len(prices))]def held_weight(signal): # Signal at t is executed at t+1 and earns from t+1 to t+2. return [0 if i < 2 else signal[i - 2] for i in range(len(signal))]def allowed_training_rows(label_end, signal_date): # Exclude missing labels and require a strictly earlier end date. return [ i for i, end in enumerate(label_end) if end is not None and date.fromisoformat(end) < signal_date ]class TimingTests(unittest.TestCase): def test_future_price_does_not_change_prior_signals(self): prices = [100, 110, 105, 120] changed = [100, 110, 105, 999] self.assertEqual(make_signal(prices), [0, 1, 0, 1]) self.assertEqual(make_signal(prices)[:3], make_signal(changed)[:3]) def test_execution_delay_and_wrong_weights(self): signal = [0, 1, 0, 1] held = held_weight(signal) self.assertEqual(held, [0, 0, 0, 1]) with self.assertRaises(AssertionError): self.assertEqual(held, signal) with self.assertRaises(AssertionError): self.assertEqual(held, [0, 0, 1, 0]) def test_label_end_is_strictly_before_signal_date(self): label_end = ["2025-01-02", "2025-01-03", "2025-01-06", None] signal_date = date.fromisoformat("2025-01-06") self.assertEqual(allowed_training_rows(label_end, signal_date), [0, 1]) def test_fit_uses_training_window_only(self): train = [100, 110] mixed_with_future = [100, 110, 120, 999] self.assertEqual(sum(train) / len(train), 105) self.assertNotEqual( sum(train) / len(train), sum(mixed_with_future) / len(mixed_with_future), )짧은 배열이지만 목적은 분명하다. 정상 구현은 통과하고, 시간 계약을 일부러 어긴 구현은 실패해야 한다. 이 네 검사는 월별 12개월 전략이나 실제 백테스트를 축소한 대체물이 아니다.
검사 1: 마지막 가격이 과거 신호를 바꾸지 않는가
첫 검사는 prices=[100,110,105,120]에서 마지막 가격만 999로 바꾼다. 원래 신호는 [0,1,0,1]이고, 변경 뒤에도 앞 세 신호는 같아야 한다.
이 검사가 확인하는 것은 좁다. 이 작은 make_signal() 함수가 앞선 세 시점의 신호를 만들 때 마지막 가격을 읽지 않는다는 성질뿐이다. 실제 SPY·IEF·GLD 가격에서 신호가 유효한지, 신호가 경제적으로 의미 있는지, 주문을 낼 수 있었는지는 말해 주지 않는다. 다만 과거 결정에 미래 관측치가 들어가면 안 된다는 기본 원칙을 직접 겨냥한다. [S1]
이런 불변성 검사는 특히 리팩터링 뒤에 유용하다. 지표를 벡터화하거나 데이터프레임 병합 순서를 바꾼 뒤에도, 미래 행을 수정했을 때 과거 신호가 바뀌면 안 된다는 최소 규칙을 계속 지킬 수 있다.
검사 2: 신호·체결·수익 귀속을 같은 행에 두지 않는가
signal=[0,1,0,1]일 때, 위 시간 계약에 따른 기대 보유 비중은 held=[0,0,0,1]이다. 신호가 t에서 확정된 뒤 t+1 종가에 실행되고, 수익이 t+1에서 t+2로 끝나는 구간부터 시작하기 때문이다.
| 비교 대상 | 값 | 테스트의 기대 |
|---|---|---|
| 시간 계약을 따른 보유 비중 | [0,0,0,1] | 정상 구현과 일치 |
| 같은 행 신호를 보유 비중으로 사용 | [0,1,0,1] | AssertionError 검출 |
| 한 행만 이동한 보유 비중 | [0,0,1,0] | AssertionError 검출 |
두 오류는 비슷해 보이지만 다르다. 같은 행 신호를 그대로 보유 비중으로 쓰면 t 종가에서 알게 된 신호가 그날의 수익을 이미 받은 것처럼 계산될 수 있다. 반대로 한 행만 이동한 값은 실행 시점과 첫 수익 귀속 구간을 하나의 이동으로 뭉개 버린다.
assertRaises()는 “틀린 구현이 정말 실패하는가”를 명시하는 장치다. [S3] 테스트가 성공했다는 말은 오류를 찾도록 설계한 비교가 의도한 예외를 냈다는 뜻이지, 실제 ETF의 체결가·스프레드·시장충격을 반영했다는 뜻은 아니다.
검사 3: 끝나지 않은 레이블을 과거 학습에 넣지 않는가
세 번째 검사는 다음 입력을 사용한다.
label_end = ["2025-01-02", "2025-01-03", "2025-01-06", None]signal_date = "2025-01-06"이 글의 보수적 규칙은 end < signal_date다. 따라서 종료일이 신호일보다 엄격히 빠르고 결측이 아닌 인덱스 [0,1]만 학습에 허용한다. 2025-01-06에 끝난 세 번째 레이블은 같은 날짜라는 이유로 제외되고, None인 네 번째 레이블도 제외된다.
이 경계가 유일하게 옳은 답은 아니다. 실제 규칙은 레이블이 정확히 어느 구간의 수익이나 사건을 관측하는지, 특성이 언제 실제로 이용 가능해졌는지를 문서화한 뒤 정해야 한다. 특히 장 마감 뒤 발표된 값과 장중 이용 가능한 값은 같은 날짜여도 정보 가용 시각이 다를 수 있다. 실제 공개 시점보다 이른 정보 귀속 자체가 누수 경로가 될 수 있다. [S1]
따라서 실제 데이터에는 단순한 date 열만 두기보다, 가능하면 feature_available_at, signal_at, execution_at, label_start, label_end처럼 역할이 다른 시간을 분리해 기록하는 편이 안전하다.
검사 4: 전처리의 평균을 학습창 안에서만 맞추는가
마지막 검사는 학습값 [100,110]의 평균이 105임을 확인한다. 그 뒤 미래값 120과 999를 섞은 [100,110,120,999]의 평균이 달라짐을 검사한다.
이 예시는 전체 스케일러 구현을 검증하는 코드가 아니다. 특정 라이브러리의 동작이나 모든 전처리 단계를 보증하지도 않는다. 대신 평균·표준편차·결측 대체 기준처럼 fit으로 추정하는 통계량은 해당 학습창 안에서만 계산해야 한다는 최소 원칙을 드러낸다. 전체 표본으로 계산한 정규화 통계는 미래 정보를 과거 학습에 전달할 수 있다. [S1]
실제 워크포워드 연구로 확장할 때는 각 학습 구간마다 전처리기를 새로 적합하고, 검증·테스트 구간에는 변환만 적용해야 한다. 학습창이 움직이면 평균도 그 창과 함께 다시 계산되어야 한다.
PASS한 범위와 아직 남은 범위
이번에 실행 기록이 있는 것은 네 가지 합성 검사뿐이다.
- 미래 가격 변경 뒤 과거 신호의 불변성
- 신호 대비 체결 지연과 수익 귀속 지연
- 레이블 종료일의 엄격한 학습 허용 조건
- 학습창 밖 값이 평균에 섞이지 않는지 확인하는 예시
반면 다음 항목은 실제 CSV와 거래일 달력이 준비된 뒤에 추가로 검사해야 한다.
- 첫 행과 마지막 행에서 신호·보유 비중·수익률을 어떻게 처리하는가
- 누락된 날짜가 휴장일인지 거래일 데이터 결측인지
- SPY·IEF·GLD를 행 번호가 아니라 실제 거래일 기준으로 어떻게 정렬하는가
- ETF별 특성을 먼저 계산한 뒤 날짜별 패널을 어떻게 분할하는가
- 레이블 종료 시각과 특성 이용 가능 시각이 실제로 겹치지 않는가
- 모든 학습창에서 전처리
fit이 창 내부 값만 사용하는가
특히 세 ETF의 날짜를 단순히 같은 길이의 배열로 맞추면 안 된다. 종목별 관측 가능 시점, 실제 거래일, 결측 사유를 확인한 뒤 날짜 기준으로 정렬해야 한다. 현재는 실제 CSV·달력·체결 기록이 없으므로, 다음 거래일 종가 실행이 시장에서 가능했는지나 수익률 계산이 시장 현실에 맞는지는 검증할 수 없다.
백테스트는 제한된 데이터, 반복적인 탐색, 과적합과 데이터 마이닝의 위험을 함께 다뤄야 하며, 백테스트 결과를 실제 성과와 동일시해서는 안 된다. [S4] 네 개의 PASS는 이런 연구 프로토콜을 대체하지 않는다.
다음 단계: 시간 계약을 행마다 적기
다음 행동은 수익률 그래프를 서둘러 만드는 일이 아니다. 먼저 연구 노트나 데이터 계약에 아래 다섯 항목을 적는다.
- 신호 확정 시각
- 주문 실행 시각
- 수익 귀속 구간
- 레이블 종료 시각
- 특성 이용 가능 시각
그다음에는 정상 결과만 확인하지 말고, 같은 행 체결, 레이블 겹침, 미래값을 섞은 전처리처럼 일부러 틀린 구현이 실패하는 테스트를 추가한다. 실패해야 할 코드가 조용히 통과하는 순간이 시점 오류를 놓치는 순간이기 때문이다.
4편의 기준선 백테스트는 실제 SPY·IEF·GLD CSV, 가격 열 정의, 거래일 정렬, 비용과 체결 가정, 실행 환경과 로그가 준비된 뒤에 시작할 수 있다. 이번 글의 합성 테스트는 그 준비를 대신하지 않는다. 이는 교육·연구 목적의 시간 검증 설계이며, 특정 ETF의 매수·매도 권유나 실주문 지침이 아니다.
Sources
- [S1] Information leakage in financial machine learning research | Zachary David; IOS Press, Algorithmic Finance | 2019-12-19 | https://journals.sagepub.com/doi/10.3233/AF-190900 ↩
- [S2] Python Release Python 3.14.5 | Python Software Foundation | 2026-05-10 | https://www.python.org/downloads/release/python-3145/ ↩
- [S3] unittest — Unit testing framework — Python 3.14.7 documentation | Python Software Foundation | Python 3.14.7 documentation version stated on source | https://docs.python.org/3.14/library/unittest.html ↩
- [S4] A Backtesting Protocol in the Era of Machine Learning | Robert D. Arnott, Campbell R. Harvey, Harry Markowitz; SSRN | 2018-11-21 | https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3275654 ↩
오류 제보·의견 보내기
글 제목과 주소가 포함된 메일 초안을 엽니다. 내용과 받는 사람을 확인한 뒤 보내주세요.
받는 사람: [email protected]
메일 앱에서 작성메일 앱이 열리지 않으면 아래 내용을 복사해 평소 쓰는 이메일에 붙여 넣으세요.
관련 글
퀀트·데이터 연구 금융 머신러닝에서 우연한 결과를 실력으로 오인할 위험과 반증 조건
금융 머신러닝의 좋은 결과를 과신하게 만드는 잡음, 시장 변화, 반복 탐색과 정보누수를 살펴봅니다. 비용과 연구자 편향까지 포함해 결과의 반증 조건을 정리합니다.
퀀트·데이터 연구 7편. 첫 머신러닝 모델: 로지스틱 회귀로 다음 리밸런싱 기간의 상승 확률을 검증하기
로지스틱 회귀를 첫 ML 기준선으로 삼아 특징, 레이블, 시간 분할과 월별 재학습을 설계합니다. 실제 성과 수치 대신 정확도와 투자 수익을 분리하는 검증 기준을 제시합니다.