같은 종목이 계속 뽑힌다면 전략 승률을 믿을 수 있을까?
주식 스크리너에 ‘승률 60%, 표본 10건’이 표시되어 있다면 어느 정도 참고할 만해 보입니다. 그런데 그중 6건이 같은 종목이 여러 날 연속 선정된 기록이라면, 이 60%는 실제로 무엇을 뜻할까요?
스크리너로 전략을 비교하는 투자자든 직접 퀀트 도구를 만드는 개발자든 빠지기 쉬운 함정입니다. 표본 수가 나타내는 것은 신호 발생 횟수일 뿐, 서로 다른 종목 수나 실제 체결된 거래 수가 아닐 수 있습니다. 승률 하나만 보면 반복 관측을 폭넓은 검증으로 착각하기 쉽습니다.
선정 신호 추적 시스템의 과거 유지보수 기록을 검토하다가 바로 이 분모 문제를 만났습니다. 아래에서는 의도적으로 구성한 합성 선정 기록 10건을 Python과 SQL로 계산해 **60%, 25%, 83.3%**라는 서로 다른 값을 얻습니다. 목표는 가장 좋아 보이는 승률을 고르는 것이 아니라 무엇을 세는지, 수익을 언제 확정하는지, 반복 관측이 얼마나 독립적인 근거인지 판단하는 것입니다.
중복 선정과 분모 선택: 6일 동안 신호 10건
시장, 전략 버전, 보유 기간을 동일하게 놓고 모든 기록이 T+5 시점에 정산되었다고 가정합니다. A부터 D는 모두 가상 종목입니다. 표의 T+5 수익률은 수수료와 슬리피지를 차감하지 않은 총수익률로, 집계 방식의 차이를 보여 주기 위해 만들었으며 실제 시세 표본이 아닙니다.
| 선정일 | 선정 종목과 T+5 수익률 | 해당 일자 선정 신호의 동일가중 T+5 평균 |
|---|---|---|
| d1 | A +5%, B -3% | +1% |
| d2 | A +4%, C -2% | +1% |
| d3 | A +3%, B -1% | +1% |
| d4 | A +6% | +6% |
| d5 | A +2% | +2% |
| d6 | A +1%, D -4% | -1.5% |
10행 중 6행이 플러스이고 모두 A에서 나왔습니다. 정산된 선정 이벤트를 한 건씩 세면 이벤트별 승률은 **6/10 = 60%**입니다. ‘신호가 발생해 결과가 확정되었을 때 수익률이 양수인 비율’을 묻는다면 이 분모는 유효합니다. 반복 선정된다는 사실만으로 계산식이 틀리는 것은 아닙니다.
하지만 이 전략이 서로 다른 종목 10개에서 검증되었다는 의미는 아닙니다. 실제 종목은 네 개뿐입니다. 관찰 기간의 각 종목별 T+5 수익률을 먼저 평균하고 그 평균이 양수인 종목을 세면 A만 해당하므로 **고유 종목 승률은 1/4 = 25%**입니다. B, C, D의 평균은 모두 음수입니다.
선정 날짜별로 묶는 방법도 있습니다. 같은 날짜에 발생해 정산된 모든 신호의 T+5 수익률을 동일가중 평균하고, 그 평균이 양수인 날짜 비율을 계산합니다. 6일 중 5일이 플러스이므로 **일자별 코호트 승률은 5/6 = 83.3%**입니다. 여섯 날짜의 T+5 코호트 평균 수익률을 다시 평균하면 약 +1.58%지만, 이는 매일 계좌가 1.58%씩 늘었다는 뜻이 아니며 그대로 연율화해서도 안 됩니다.
| 지표 | 결과 | 무엇을 보여 주나 |
|---|---|---|
| 이벤트별 승률 | 60% (6/10) | 정산된 선정 신호 중 플러스였던 비율 |
| 고유 종목 승률 | 25% (1/4) | 종목별 평균 T+5 수익률이 플러스인 종목 비율 |
| 일자별 코호트 승률 | 83.3% (5/6) | 선정일별 동일가중 평균이 플러스인 날짜 비율 |
이 예에서는 일자별 승률이 오히려 가장 높습니다. 중복 선정이 이벤트별 승률을 반드시 부풀리지는 않습니다. 바뀌는 것은 특정 종목이 집계에서 차지하는 가중치입니다. 반복 선정되는 종목이 손실 종목이라면 이벤트별 승률이 고유 종목 승률보다 더 낮을 수도 있습니다. 전략을 비교하기 전에 표시된 값이 ‘선정 이벤트’, ‘서로 다른 종목’, ‘선정 날짜’ 중 무엇을 세는지 확인해야 합니다.
Python으로 세 가지 승률 직접 재현하기
아래는 Python 3.13.5 표준 라이브러리만 사용해 실제로 실행한 전체 스크립트입니다. 입력에 합성 데이터 10건이 들어 있는 것이지 ‘10줄짜리 스크립트’가 아닙니다. 종목별·날짜별 평균 T+5 수익률과 세 가지 승률을 모두 출력합니다. 결과 단언 7개와 입력 구조 단언 1개가 있으며 마지막 ASSERTIONS_PASS=7은 결과 단언만 센 표시입니다.
#!/usr/bin/env python3
"""Synthetic settled T+5 selection-outcome metric comparison, no real market data."""
from collections import defaultdict
from statistics import mean
# Entirely fictional stocks and T+5 returns (decimal units).
# Every row is a distinct snapshot-date pick in the SAME market, strategy, and horizon.
ROWS = [
("d1", "A", 0.05), ("d1", "B", -0.03),
("d2", "A", 0.04), ("d2", "C", -0.02),
("d3", "A", 0.03), ("d3", "B", -0.01),
("d4", "A", 0.06),
("d5", "A", 0.02),
("d6", "A", 0.01), ("d6", "D", -0.04),
]
def calc(rows):
assert rows and all(isinstance(r, tuple) and len(r) == 3 for r in rows)
symbol_returns = defaultdict(list)
date_returns = defaultdict(list)
for day, symbol, settled_return in rows:
symbol_returns[symbol].append(settled_return)
date_returns[day].append(settled_return)
symbol_means = {symbol: mean(xs) for symbol, xs in symbol_returns.items()}
date_means = {day: mean(xs) for day, xs in date_returns.items()}
return {
"settled_rows": len(rows),
"distinct_symbols": len(symbol_returns),
"distinct_pick_dates": len(date_returns),
"sample_win_rate": sum(ret > 0 for _, _, ret in rows) / len(rows),
"unique_symbol_win_rate": sum(ret > 0 for ret in symbol_means.values()) / len(symbol_means),
"daily_cohort_win_rate": sum(ret > 0 for ret in date_means.values()) / len(date_means),
"mean_daily_cohort_return": mean(date_means.values()),
"symbol_mean_returns": symbol_means,
"date_cohort_returns": date_means,
}
if __name__ == "__main__":
import json
result = calc(ROWS)
assert result["settled_rows"] == 10
assert result["distinct_symbols"] == 4
assert result["distinct_pick_dates"] == 6
assert result["sample_win_rate"] == 0.6
assert result["unique_symbol_win_rate"] == 0.25
assert abs(result["daily_cohort_win_rate"] - 5 / 6) < 1e-12
assert abs(result["mean_daily_cohort_return"] - 0.095 / 6) < 1e-12
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
print("ASSERTIONS_PASS=7")
전체 표준 출력입니다. 실제 시장 백테스트가 아닌 합성 데이터의 결과입니다.
{
"daily_cohort_win_rate": 0.8333333333333334,
"date_cohort_returns": {
"d1": 0.010000000000000002,
"d2": 0.01,
"d3": 0.009999999999999998,
"d4": 0.06,
"d5": 0.02,
"d6": -0.015
},
"distinct_pick_dates": 6,
"distinct_symbols": 4,
"mean_daily_cohort_return": 0.015833333333333335,
"sample_win_rate": 0.6,
"settled_rows": 10,
"symbol_mean_returns": {
"A": 0.035,
"B": -0.02,
"C": -0.02,
"D": -0.04
},
"unique_symbol_win_rate": 0.25
}
ASSERTIONS_PASS=7
세 분모는 이벤트별 6/10, 고유 종목별 1/4, 날짜별 5/6입니다. 날짜별 T+5 평균 0.01583333(약 +1.58%)은 실제 계좌의 일일 순수익률이 아닙니다. 실행 가능한 전체 코드와 출력을 함께 실었으므로 직접 재현할 수 있습니다. 원본 UTF-8 파일의 바이트 단위 비교에 사용하는 SHA-256도 남깁니다.
- Python 스크립트:
0b554335c960b4a58eab671a693ef64cdba4ab46a6c6f653a93fa2ca220a7b8f - 표준 출력:
7b91fcdda0c6bea63d05788f25815af16321c647d5342463601599a81db1b8f2
해시는 원래 UTF-8 파일의 바이트를 기준으로 합니다. 웹페이지에서 복사하면 줄바꿈이나 공백이 바뀌어 해시가 달라질 수 있지만 계산 결과는 독립적으로 확인할 수 있습니다. 실제 시세 데이터베이스나 거래 계좌에는 접속하지 않았습니다.
승률 통계의 두 가지 추가 함정: 중복 저장과 정산 시점
선정 신호 추적 시스템의 과거 유지보수 기록을 살펴보며 더 합리적이라고 느낀 설계는 실제 선정 이벤트를 모두 보존하고 이벤트별·고유 종목별·일자별 코호트 승률을 나란히 보여 주는 것입니다. 이 통계 정의는 공개되지 않은 과거 내부 엔지니어링 기록에서 나온 것으로, 이번 글에서 운영 DB를 다시 조회하지는 않았습니다. Python 실험은 계산 방식의 검증이지 실제 전략 성적의 증거가 아닙니다.
먼저 두 종류의 ‘중복’을 구분해야 합니다. 같은 종목이 월요일과 화요일 각각 선정되었다면 추적 가능한 별개 신호입니다. 반면 예약 작업이 재시도되면서 같은 날짜, 시장, 전략, 종목, 보유 기간의 동일 이벤트를 두 번 기록했다면 중복 적재 오류일 수 있습니다. 전자를 모두 삭제해서도, 후자를 새 표본으로 간주해서도 안 됩니다. 정확한 이벤트 식별 키는 신호 출처와 전략 정의에 따라 달라집니다.
더 숨겨진 문제는 정산 기준입니다. T+5는 예약 작업 실행 다섯 번이 아니라 해당 시장의 다섯 거래 세션을 뜻해야 합니다. 청산에 사용할 가격도 뒤늦게 작업이 실행된 시점의 현재가가 아닌, 미리 정한 정산일의 과거 가격이어야 합니다. 아직 만기가 오지 않았거나 시세가 누락되어 pending인 기록을 정산 완료 승률의 분모에 넣어서는 안 됩니다. 과거 유지보수 기록에는 거래일 달력, 과거 종가, 전략 버전 분리 방식이 언급되지만 현재 운영 환경을 독립 검증한 결과는 아닙니다.
‘후보가 없음’, ‘아직 정산 전’, ‘정산되었지만 모두 손실’은 전혀 다른 상태입니다. 이벤트 중복 저장 여부, 정산 가격, 정산 완료 기록만 포함했는지 먼저 확인하고 승률을 비교하는 편이 가장 높은 숫자만 고르는 것보다 유용합니다.
보유 기간이 겹치면 종목 중복 제거만으로 독립 표본이 되지 않는다
종목별로 묶더라도 표본들이 통계적으로 독립인 것은 아닙니다. A가 6일 연속 선정되고 모든 기록이 이후 5거래일의 수익률을 측정한다면 인접한 전망 구간이 같은 가격 움직임을 공유합니다. 종목별 평균을 내더라도 여러 종목에 동시에 작용하는 시장 요인이 사라지는 것은 아닙니다. 서로 다른 종목 수, 서로 다른 선정일 수, 통계적으로 유효한 독립 표본 수는 같지 않습니다.
따라서 연속한 T+5 기록 10개를 무작위로 학습·테스트 세트에 나눈 뒤 독립적 일반화 성능을 검증했다고 주장할 수 없습니다. scikit-learn의 TimeSeriesSplit 문서는 미래 정보로 과거를 학습하지 않도록 시간순으로 나누는 방법을 설명합니다. 그룹 교차검증 가이드는 관련 샘플 그룹이 학습과 테스트에 동시에 포함되는 문제를 다룹니다. 종목 그룹화와 시간 분리는 서로 다른 위험을 줄입니다.
결과 라벨이 미래 5거래일에 걸쳐 있다면 학습·테스트 라벨의 정보 구간이 분할 경계에서 겹칠 수 있습니다. Hudson & Thames의 PurgedKFold 구현은 테스트 라벨의 구간과 겹치는 학습 표본을 제외하는 예시입니다. 이는 외부 소스 코드 참고 자료이지 여기서 직접 실행한 퀀트 모델이 아닙니다. 단순한 날짜순 정렬이나 하루 간격만으로는 구간 중복이 해결되지 않습니다.
이 방법들의 목적은 추론의 신뢰도를 높이는 것이지 모든 스크리너에 정밀해 보이는 ‘유효 표본 수’ 하나를 표시하자는 뜻이 아닙니다. 실제 전략을 선택하는 사람에게는 선정 이벤트 수, 고유 종목 수, 선정 날짜, 보유 기간, 미정산 비율을 함께 보여 주는 편이 더 도움이 됩니다.
읽기 전용 SQL로 점검하기: 정산 이벤트, 고유 종목, 선정일
이제 실무적인 질문으로 돌아가 봅시다. 이 전략의 승률을 비교에 사용해도 되는가? 원시 데이터가 있다면 선정 이벤트와 정산 내역을 보존하고, 같은 시장·전략 버전·진입 가정·T+N을 기준으로 세 가지 승률을 나란히 확인해야 합니다. 비용 차감 전 총수익인지 비용 차감 후 순수익인지도 명시해야 합니다.
화면에 보이는 단일 승률을 믿기보다 동일 시장·동일 전략 버전·동일 T+N·동일 선정일 구간의 정산 완료 이벤트부터 조회하세요. 다음은 PostgreSQL/SQLite에서 사용할 수 있는 일반적인 읽기 전용 집계 SQL입니다. your_settled_outcomes, example-v2, 컬럼 이름은 예시일 뿐, 어떤 실제 운영 DB의 테이블 구조를 가정하지 않습니다. 실제 필드와 정산 기준에 맞춰 바꾸고 읽기 전용 복제본에서 확인하세요.
-- Read-only PostgreSQL/SQLite template. Replace table/columns and filters.
WITH settled AS (
SELECT snapshot_date, tv_symbol, return_pct
FROM your_settled_outcomes
WHERE market = 'US'
AND strategy_id = 'example-v2'
AND horizon_days = 5
AND snapshot_date >= '2026-07-01'
AND snapshot_date < '2026-10-01'
AND settled_trade_date IS NOT NULL
AND return_pct IS NOT NULL
), by_symbol AS (
SELECT tv_symbol, AVG(return_pct) AS mean_return
FROM settled GROUP BY tv_symbol
), by_day AS (
SELECT snapshot_date, AVG(return_pct) AS mean_return
FROM settled GROUP BY snapshot_date
)
SELECT
(SELECT COUNT(*) FROM settled) AS settled_rows,
(SELECT COUNT(DISTINCT tv_symbol) FROM settled) AS unique_symbols,
(SELECT COUNT(DISTINCT snapshot_date) FROM settled) AS unique_pick_days,
(SELECT AVG(CASE WHEN return_pct > 0 THEN 1.0 ELSE 0.0 END) FROM settled) AS sample_win_rate,
(SELECT AVG(CASE WHEN mean_return > 0 THEN 1.0 ELSE 0.0 END) FROM by_symbol) AS unique_symbol_win_rate,
(SELECT AVG(CASE WHEN mean_return > 0 THEN 1.0 ELSE 0.0 END) FROM by_day) AS daily_cohort_win_rate;
SQL도 Python과 같은 세 단계로 계산합니다. settled는 정산 완료 이벤트, by_symbol은 종목별 평균, by_day는 선정일별 평균입니다. Linux x86_64의 Python 3.13.5 sqlite3 모듈(sqlite3.sqlite_version = 3.46.1)로 합성 10건의 동등한 쿼리를 실행한 결과 **이벤트 10건, 종목 4개, 선정일 6일, 승률 0.6 / 0.25 / 0.833333…**이 나왔습니다. PostgreSQL이나 운영 DB는 조회하지 않았습니다.
같은 날짜의 같은 종목이 중복 적재되었는지 확인하려면 정산 완료 결과가 아닌 원시 선정 이벤트 테이블에 두 번째 읽기 전용 쿼리를 실행합니다. 아래 테이블과 필터는 예시이므로 자신의 시장, 전략 버전, 보유 기간과 날짜 범위에 맞게 바꿔야 합니다.
SELECT snapshot_date, tv_symbol, COUNT(*) AS repeat_rows
FROM your_signal_events
WHERE market = 'US' AND strategy_id = 'example-v2' AND horizon_days = 5
AND snapshot_date >= '2026-07-01'
AND snapshot_date < '2026-10-01'
GROUP BY snapshot_date, tv_symbol
HAVING COUNT(*) > 1;
이 쿼리는 같은 시장·전략·보유 기간 내 같은 날짜와 종목의 중복 조합을 반환합니다. 위의 합성 선정 이벤트 10건을 SQLite로 확인하면 처음에는 결과가 없지만, 2026-07-01의 A 이벤트를 의도적으로 복제하면 repeat_rows = 2가 나옵니다. 쿼리가 작동한다는 증거일 뿐, 실제 서비스에 중복 데이터가 있다는 뜻은 아닙니다.
결과가 나왔다고 바로 삭제하면 안 됩니다. 같은 날 다른 출처나 다른 시점에 발생한 합법적인 신호도 이 필드를 공유할 수 있습니다. 실제 이벤트 ID, 발생 시각, 출처를 대조해 중복 여부를 결정하세요. 다른 날짜에 같은 종목이 다시 선정된 기록은 적재 오류가 아닙니다. 미정산·가격 누락·과거 전략 버전은 따로 세고, 실제 투자 평가에는 손익 분포와 비용이 필요합니다.
‘추천 목록을 매일 매수했을 때의 투자 성과’를 말하려면 자금과 포지션, 겹친 보유 종목, 수수료, 유동성, 시장 충격, 슬리피지를 반영한 포트폴리오 백테스트가 별도로 필요합니다. 앞서 계산한 +1.58%에는 이런 조건이 없으므로 실현 가능한 일일 포트폴리오 수익률이 아닙니다.
다른 관련 주제로는 여러 전략의 주식 점수를 비교하는 방법이 있습니다. 거기서는 서로 다른 출처의 ‘80점’이 비교 가능한지를 다룹니다. 이번 글은 더 앞단의 문제입니다. 선정 이벤트 하나, 종목 하나, 날짜 하나, 실제 체결 거래 하나는 동일한 통계 단위가 아닙니다.
다음에 ‘승률 60%, 표본 10건’을 본다면 이렇게 물어보세요. 10건은 신호 발생 10건인가, 서로 다른 종목 10개인가? 몇 거래일에 걸쳐 있나? T+N 결과가 실제로 정산된 건 몇 개인가? 답을 알면 Python과 SQL로 숫자의 의미를 검증할 수 있습니다. 그다음 보유 기간 중복, 거래 비용, 위험 분포까지 살펴보고 어떤 전략을 신뢰할지 판단할 수 있습니다.
검증 범위: Python 3.13.5, SQLite 3.46.1과 합성 데이터. 엔지니어링 맥락은 과거 유지보수 기록에서 가져왔습니다. 실제 시세에 대한 운영 감사나 체결 가능한 포트폴리오 백테스트는 수행하지 않았습니다.