4 / 12 · AUTO TRADING WIKI
코스피200 데이터 수집하기
종목 목록과 일봉 데이터를 수집하고 누락·중복·날짜를 점검합니다.
시세 한 건을 조회했다면 다음 목표는 계산에 쓸 일봉 자료를 일정한 형식으로 모으는 것이다. 이 장에서는 대상 종목 목록 고정 → 기간별 시세 조회 → 검증 → CSV와 실행 요약 저장 순서로 진행한다. 먼저 실제 종목 1개, 다음 5개를 확인한 뒤 범위를 넓힌다.
이 책의 CSV와 다음 장의 SQL은 symbol, trade_date, open, high, low, close, volume이라는 공통 컬럼을 사용한다. 모든 날짜는 거래소의 거래일 기준이며, 일봉은 해당 거래일의 수집을 마친 데이터만 사용한다.
1. 코스피200 목록의 기준일 보관하기
코스피200은 분석 범위를 일정하게 정하기 위한 출발점이다. 구성 종목은 바뀔 수 있다. 현재 구성 종목만 골라 과거 성과를 계산하면 당시 제외되었거나 상장폐지된 종목을 놓치는 생존 편향이 생길 수 있다. 이 문제는 수집 성공 여부와 별도로 기록한다.
KRX Data Marketplace에서 지수의 구성 종목과 조회 기준일을 확인하고, 제공되는 내려받기 기능으로 원본을 보관한다. 화면의 메뉴와 다운로드 조건은 바뀔 수 있으므로 비공식 URL이나 로그인 우회 방식에 의존하지 않는다.
원본을 outputs/universe/raw/에 두고, 프로젝트가 읽는 outputs/universe/kospi200.csv를 다음 컬럼으로 정리한다.
| 컬럼 | 예시 형식 | 의미 |
|---|---|---|
symbol |
005930, 0126Z0처럼 여섯 자리 문자열 |
ASCII 숫자·영문 대문자를 원본대로 보존한다 |
name |
내려받은 종목명 | 표시용 이름이며 식별 키로 쓰지 않는다 |
snapshot_date |
YYYY-MM-DD |
구성 종목 목록의 조회 기준일 |
source |
출처 URL 또는 원본 파일명 | 목록을 확인한 근거 |
종목코드는 숫자만 있다고 가정하지 않는다. 예를 들어 삼성에피스홀딩스 공식 주식정보는 0126Z0을 사용한다. 이 책은 [A-Z0-9]{6}을 공통 형식으로 사용하며, 소문자·Unicode 유사 문자는 거부한다.
종목 수가 예상과 다르면 단순히 200개에 맞추려고 행을 삭제하지 않는다. 조회한 지수·기준일·중복·빈 코드를 확인한다. 현재 목록만 확보한 상태라면 결과 문서에도 “현재 구성 종목을 과거에 적용한 탐색”이라고 명시한다.
2. 일봉 조회의 기준 정하기
기간별 일봉은 고가·저가·종가뿐 아니라 다음 장에서 사용할 시가도 함께 저장한다. 거래량 0은 가능하지만 가격이 0인 행을 정상 거래일 가격으로 임의 보정하지 않는다. 거래 정지나 공급자 표시 방식인지 확인하고 별도 점검 대상으로 둔다.
이번 실습의 기준은 다음과 같다.
- 시장 구분
J: KRX 기준 시세 - 기간 구분
D: 일봉 - 가격 구분
0: 수정주가 - 한 행의 식별자:
(symbol, trade_date) - 계산 순서: 종목별 거래일 오름차순
이 값과 응답 필드는 한국투자증권 공식 기간별시세 예제를 기준으로 했다. 공식 예제는 한 번의 호출에서 최대 100건을 안내한다. 여기서는 달력 기준 80일 이하로 구간을 잘라 그 제한 안에서 요청한다. 긴 기간을 한 번 호출하고 전체를 받았다고 가정하지 않는다.
수정주가는 기업행사에 따라 과거 값이 바뀔 수 있다. 수집 일시, 가격 구분과 원본을 함께 기록한다. 일봉 지표 계산과 주문 수량·현금 정산의 가격 체계가 다르면 시뮬레이션도 달라지므로, 실제 체결을 재현하는 단계에서는 기업행사 처리까지 별도로 설계한다.
3. 응답을 공통 컬럼으로 정리하기
src/collect_daily.py에 다음 내용을 작성한다. 3장의 ReadOnlyKis를 그대로 사용한다. 공급자 필드와 프로젝트 필드를 바꾸는 부분을 별도 함수로 두면 가짜 응답으로도 검증할 수 있다.
import argparse
from datetime import date, timedelta
from pathlib import Path
import time
import pandas as pd
from src.kis_readonly import ROOT, ReadOnlyKis, load_config
FIELDS = {
"stck_bsop_date": "trade_date",
"stck_oprc": "open",
"stck_hgpr": "high",
"stck_lwpr": "low",
"stck_clpr": "close",
"acml_vol": "volume",
}
PRICE_COLUMNS = ["open", "high", "low", "close"]
COLUMNS = ["symbol", "trade_date", *PRICE_COLUMNS, "volume"]
def normalize(symbol, records):
if not records:
raise ValueError("No daily records returned")
frame = pd.DataFrame(records)
missing = set(FIELDS) - set(frame.columns)
if missing:
raise ValueError(f"Missing daily fields: {sorted(missing)}")
frame = frame.loc[frame["stck_bsop_date"].fillna("").ne(""), list(FIELDS)]
frame = frame.rename(columns=FIELDS).copy()
if frame.empty:
raise ValueError("All returned dates are empty")
frame["trade_date"] = pd.to_datetime(
frame["trade_date"], format="%Y%m%d", errors="raise"
).dt.date
for column in [*PRICE_COLUMNS, "volume"]:
values = pd.to_numeric(frame[column], errors="raise")
if values.isna().any() or values.mod(1).ne(0).any():
raise ValueError(f"Non-integer value in {column}")
frame[column] = values.astype("int64")
invalid = (
frame[PRICE_COLUMNS].le(0).any(axis=1)
| frame["volume"].lt(0)
| frame["high"].lt(frame[["open", "close", "low"]].max(axis=1))
| frame["low"].gt(frame[["open", "close", "high"]].min(axis=1))
)
if invalid.any():
raise ValueError(f"Invalid OHLCV rows: {int(invalid.sum())}")
frame["symbol"] = symbol
frame = frame.drop_duplicates()
if frame.duplicated(["symbol", "trade_date"]).any():
raise ValueError("Conflicting rows for the same symbol and date")
return frame[COLUMNS].sort_values(["symbol", "trade_date"]).reset_index(drop=True)
def collect_symbol(client, symbol, start, end):
records = []
cursor = start
while cursor <= end:
window_end = min(cursor + timedelta(days=79), end)
body = client.quotations("daily", {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": symbol,
"FID_INPUT_DATE_1": cursor.strftime("%Y%m%d"),
"FID_INPUT_DATE_2": window_end.strftime("%Y%m%d"),
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "0",
})
output = body.get("output2")
if not isinstance(output, list):
raise ValueError("output2 must be a list")
records.extend(output)
cursor = window_end + timedelta(days=1)
time.sleep(1.0)
frame = normalize(symbol, records)
if frame["trade_date"].lt(start).any() or frame["trade_date"].gt(end).any():
raise ValueError("The response contains a date outside the requested range")
return frame
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--start", type=date.fromisoformat, required=True)
parser.add_argument("--end", type=date.fromisoformat, required=True)
parser.add_argument("--limit", type=int, default=1)
args = parser.parse_args()
if args.start > args.end or args.limit < 1:
parser.error("Check the date range and limit")
universe = pd.read_csv(
ROOT / "outputs/universe/kospi200.csv", dtype={"symbol": "string"}
)
symbols = universe["symbol"]
if symbols.isna().any() or not symbols.str.fullmatch(r"[A-Z0-9]{6}").all():
raise ValueError("Universe contains invalid symbols")
if symbols.duplicated().any():
raise ValueError("Universe contains duplicate symbols")
target = ROOT / "outputs/daily"
target.mkdir(parents=True, exist_ok=True)
manifest = []
client = ReadOnlyKis(load_config())
try:
for symbol in symbols.head(args.limit):
try:
frame = collect_symbol(client, symbol, args.start, args.end)
name = f"{symbol}_{args.start}_{args.end}.csv"
frame.to_csv(target / name, index=False, encoding="utf-8-sig")
manifest.append({"symbol": symbol, "status": "ok", "rows": len(frame),
"first": frame.trade_date.min(), "last": frame.trade_date.max()})
except Exception as exc:
manifest.append({"symbol": symbol, "status": "failed", "rows": 0,
"error_type": type(exc).__name__})
finally:
client.close()
summary = pd.DataFrame(manifest)
summary.to_csv(target / "collection_manifest.csv", index=False, encoding="utf-8-sig")
print(summary.to_string(index=False))
if summary["status"].ne("ok").any():
raise SystemExit(1)
if __name__ == "__main__":
main()CSV를 읽을 때 symbol을 문자열로 지정해야 005930의 앞자리 0이 유지된다. CSV 인코딩은 Windows에서 열기 편한 utf-8-sig로 맞췄다. pandas 공식 read_csv 문서
4. 한 종목부터 수집하기
다음은 이미 끝난 기간을 지정한 실행 예시다. 자신의 수집 목적에 맞게 날짜를 바꾸되 아직 끝나지 않은 당일 일봉은 완료 자료와 섞지 않는다.
.\.venv\Scripts\python.exe -m py_compile src/collect_daily.py
.\.venv\Scripts\python.exe -m src.collect_daily --start 2026-01-01 --end 2026-10-16 --limit 1성공하면 outputs/daily/종목코드_시작일_종료일.csv와 collection_manifest.csv가 생긴다. rows가 양수이고 first·last가 요청 범위 안에 있어야 한다. 시작일이 휴일이거나 상장일보다 빠르면 첫 거래일이 시작일보다 늦을 수 있다.
한 종목의 CSV를 열어 순서와 숫자를 확인한 뒤 --limit 5로 확장한다. 전체 종목을 수집할 때도 정상 종목 수, 실패 종목 수, 범위별 행 수를 확인한다. 코드의 1초 간격은 교육용 기본값이며 현재 호출 한도를 보장하는 값이 아니다. 서버 환경별 한도와 오류 안내는 한국투자증권 API 포털에서 확인한다.
응답이 빈 구간은 상장 전·휴일·API 이상 여부를 확인한다. 위 예제는 모든 구간이 합쳐진 최종 결과만 검사하므로 거래일 누락을 완전히 검출하지는 않는다. 거래소 영업일 달력과 대조하고 거래 정지·신규 상장 사유를 기록하는 검사를 운영 전에 추가한다.
5. 부분 성공을 전체 성공으로 보지 않기
collection_manifest.csv의 실패 행은 해당 실행에서 완성된 CSV로 인정하지 않는다. 같은 파일명이 이전 실행에서 이미 존재할 수 있으므로 파일 존재 여부만으로 성공을 판단하지 않는다. 운영용으로 확장할 때는 실행 ID별 폴더와 완료 표시를 사용하고, 이번 실행에서 성공한 파일만 다음 단계가 읽도록 한다.
정상 데이터의 확인 순서는 요청 목록 → 성공 목록 → 파일 → 필수 컬럼 → 중복 → 가격 범위 → 거래일 누락이다. 휴일을 채우려고 전날 가격으로 새로운 일봉을 만들지 않는다. 원본 자료를 남겨야 누락 원인이 수집인지 공급자 데이터인지 다시 확인할 수 있다.
6. Codex에 수집 품질 검사 요청하기
auto-trading-lab의 코스피200 일봉 수집기를 점검해줘.
구성 종목 목록의 기준일과 원본 출처를 남기고 symbol은 ASCII 숫자·영문 대문자 6자리 문자열로 처리해줘.
공식 기간별시세 API의 건수 제한 안에서 기간을 분할해줘.
symbol/trade_date/open/high/low/close/volume 형식을 고정하고,
중복 충돌·음수 거래량·잘못된 OHLC·미완료 일봉을 검사해줘.
실패 종목이 있으면 전체 성공으로 보고하지 말고 실행 요약에 구분해줘.
가짜 응답으로 빈 결과, 역순 날짜, 중복 충돌을 검사하고 실제 API 실행 여부를 명시해줘.
이번 작업은 CSV까지만이며 DB 저장과 주문은 다음 단계로 남겨줘.
7. 오류 대응과 완료 기준
| 문제 | 확인과 대응 |
|---|---|
| 종목코드가 다섯 자리로 보임 | CSV 읽기에서 문자열 형식을 지정했는지 확인한다 |
| 긴 기간인데 약 100행만 있음 | 조회 기간 분할과 응답 제한을 확인한다 |
| 가격 0 또는 OHLC 모순 | 자동 보정하지 않고 원본과 거래 상태를 확인한다 |
| 중복 날짜의 가격이 다름 | 수정주가 구분·시장·수집 시점을 비교한다 |
| 일부 종목만 실패 | 오류 종류와 API 오류 코드를 별도로 확인하고 실패 대상을 다시 수집한다 |
이번 장을 마치면 기준일이 있는 종목 목록, 검증된 일봉 CSV, 성공·실패 요약이 있어야 한다. 가짜 응답 검사를 통과했다는 것과 실제 전체 종목 수집을 마쳤다는 것은 서로 다른 검증 결과다. 다음 장에서는 이번 실행에서 검증된 CSV만 MySQL에 저장한다.
공유 링크를 복사해 이어서 읽을 수 있습니다.