yfinance로 국내 주식 데이터 받기: 수정주가·결측·티커를 먼저 확인하는 법

백테스트나 기술지표를 만들기 전에 먼저 정해야 할 것은 매매 규칙이 아니라 입력 데이터입니다. 같은 이동평균선이라도 어떤 가격을 사용했는지, 종목 코드가 맞는지, 결측과 이상값을 어떻게 처리했는지에 따라 신호가 달라질 수 있습니다.

yfinance는 별도 증권사 API 연결 없이 연구용 일봉 데이터를 받기 편리한 도구입니다. 다만 내려받은 DataFrame을 바로 지표에 넣기보다 티커, 원본 가격과 조정 가격, 기본 품질 검사를 먼저 확인해야 합니다.

이 글의 핵심

  • 국내 종목은 여섯 자리 코드 뒤에 코스피 .KS, 코스닥 .KQ 접미사를 붙여 요청합니다.
  • CloseAdj Close를 함께 보존하고, 분석 목적에 맞는 가격을 선택합니다.
  • 저장 전 결측·중복 날짜·OHLC 관계·음수 거래량을 검사해 잘못된 입력을 일찍 찾습니다.

1. 티커를 먼저 명확히 정한다

국내 주식은 종목 코드만으로 시장을 완전히 구분하기 어렵습니다. yfinance 요청에서는 코스피 종목에 .KS, 코스닥 종목에 .KQ를 붙입니다. 예를 들어 삼성전자는 005930.KS 형식입니다.

수집 함수에서 종목 코드와 시장 구분을 따로 받으면, 문자열을 직접 붙일 때 생기는 오타를 줄일 수 있습니다. 응답이 비어 있으면 바로 저장하지 말고 코드·시장 구분·네트워크 상태를 먼저 확인해야 합니다.

2. 원본 종가와 수정 종가를 섞지 않는다

yfinance.download()auto_adjustmulti_level_index 같은 옵션을 제공합니다. 이 글의 코드는 auto_adjust=False로 원본 OHLC와 Adj Close를 함께 받고, 단일 종목 데이터는 multi_level_index=False로 평평한 열 구조를 요청합니다. 현재 옵션의 기본값과 동작은 yfinance download 공식 API 문서에서 확인할 수 있습니다.

Close는 해당 거래일의 원본 종가이고, Adj Close는 제공처가 과거 가격에 조정을 반영한 값입니다. 장기 수익률을 비교하거나 분할·배당으로 인한 가격 단절을 완화하는 분석에는 조정 가격이 유용할 수 있습니다. 반면 실제 주문 가격과 체결을 검토할 때는 원본 가격, 호가, 주문 제도를 별도로 확인해야 합니다.

중요한 점은 둘 중 하나가 항상 맞는 것이 아니라, 전략 설명에 어떤 가격을 썼는지 남기는 일입니다. 아래 코드는 두 열을 함께 저장하고 adjustment_factor를 만들어 차이를 확인할 수 있게 합니다.

3. 저장 전 확인할 네 가지

데이터 품질 검사는 복잡한 통계보다 기본 관계를 먼저 봅니다.

점검 확인하는 문제
핵심 열 결측 가격·거래량이 비어 있는 거래일
중복 날짜 같은 거래일이 두 번 저장되는 문제
OHLC 관계 저가가 시가·종가·고가보다 높거나 고가가 더 낮은 오류
거래량 부호 음수 거래량처럼 해석할 수 없는 값

이 검사는 거래일 사이의 모든 빈 날짜를 결측으로 취급하지 않습니다. 거래소 휴장일은 정상적으로 일봉 데이터에 없을 수 있기 때문입니다. 대신 내려받은 행 안에서 가격·거래량이 비었는지와 날짜·OHLC 관계가 일관적인지 확인합니다.

4. 전체 Python 코드

필요한 패키지는 한 번 설치합니다.

TEXT
python -m pip install yfinance pandas pyarrow

아래 코드는 종목 코드와 시장 구분으로 일봉을 받고, 원본 OHLCV·수정 종가·배당·분할 정보를 보존한 뒤 품질 검사를 통과한 데이터만 Parquet로 저장합니다. period는 필요에 따라 1y, 5y, 10y, max처럼 바꿀 수 있습니다.

PYTHON
#!/usr/bin/env python3
"""yfinance로 국내 주식 일봉을 받고 기본 품질 검사를 거쳐 Parquet로 저장한다."""

from __future__ import annotations

import argparse
from pathlib import Path

import pandas as pd
import yfinance as yf


REQUIRED_COLUMNS = ("Open", "High", "Low", "Close", "Adj Close", "Volume")


def normalize_columns(price: pd.DataFrame) -> pd.DataFrame:
    if not isinstance(price.columns, pd.MultiIndex):
        return price
    if price.columns.nlevels != 2:
        raise ValueError("예상하지 못한 다중 열 구조입니다.")
    first_level = price.columns.get_level_values(0)
    if set(REQUIRED_COLUMNS).issubset(first_level):
        price.columns = first_level
        return price
    second_level = price.columns.get_level_values(1)
    if set(REQUIRED_COLUMNS).issubset(second_level):
        price.columns = second_level
        return price
    raise ValueError("OHLCV 열을 찾지 못했습니다.")


def build_ticker(code: str, market: str) -> str:
    clean_code = code.strip().zfill(6)
    suffix_by_market = {"KOSPI": ".KS", "KOSDAQ": ".KQ"}
    normalized_market = market.strip().upper()
    if normalized_market not in suffix_by_market:
        raise ValueError("시장 구분은 KOSPI 또는 KOSDAQ만 사용할 수 있습니다.")
    if not clean_code.isdigit() or len(clean_code) != 6:
        raise ValueError("종목 코드는 여섯 자리 숫자로 입력해야 합니다.")
    return f"{clean_code}{suffix_by_market[normalized_market]}"


def download_daily_price(ticker: str, period: str) -> pd.DataFrame:
    price = yf.download(
        tickers=ticker,
        period=period,
        interval="1d",
        auto_adjust=False,
        actions=True,
        group_by="column",
        multi_level_index=False,
        progress=False,
        threads=False,
        timeout=20,
    )
    if price.empty:
        raise RuntimeError(f"가격 데이터를 받지 못했습니다: {ticker}")
    price = normalize_columns(price).copy()
    missing_columns = [column for column in REQUIRED_COLUMNS if column not in price.columns]
    if missing_columns:
        raise ValueError(f"필수 열이 없습니다: {', '.join(missing_columns)}")
    price.index = pd.to_datetime(price.index).tz_localize(None)
    price.index.name = "trade_date"
    price = price.loc[:, [*REQUIRED_COLUMNS, "Dividends", "Stock Splits"]].copy()
    price["Dividends"] = price["Dividends"].fillna(0.0)
    price["Stock Splits"] = price["Stock Splits"].fillna(0.0)
    price["adjustment_factor"] = price["Adj Close"].div(price["Close"]).where(price["Close"].ne(0))
    return price


def validate_daily_price(price: pd.DataFrame) -> dict[str, int]:
    missing_core = int(price.loc[:, REQUIRED_COLUMNS].isna().any(axis=1).sum())
    duplicated_dates = int(price.index.duplicated().sum())
    invalid_ohlc_mask = (price["Low"] > price[["Open", "Close", "High"]].min(axis=1)) | (price["High"] < price[["Open", "Close", "Low"]].max(axis=1))
    invalid_ohlc = int(invalid_ohlc_mask.sum())
    negative_volume = int((price["Volume"] < 0).sum())
    report = {
        "rows": len(price),
        "missing_core_rows": missing_core,
        "duplicated_dates": duplicated_dates,
        "invalid_ohlc_rows": invalid_ohlc,
        "negative_volume_rows": negative_volume,
    }
    if any(report[key] for key in ("missing_core_rows", "duplicated_dates", "invalid_ohlc_rows", "negative_volume_rows")):
        invalid_dates = ", ".join(price.index[invalid_ohlc_mask].strftime("%Y-%m-%d").tolist()[:5])
        raise ValueError(f"품질 검사 실패: {report}; OHLC 확인 거래일: {invalid_dates or '-'}")
    return report


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--code", default="035720", help="여섯 자리 종목 코드")
    parser.add_argument("--market", choices=("KOSPI", "KOSDAQ"), default="KOSPI")
    parser.add_argument("--period", default="5y", help="yfinance 기간 문자열")
    parser.add_argument("--output", type=Path, default=Path("krx_daily_price.parquet"))
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    ticker = build_ticker(args.code, args.market)
    price = download_daily_price(ticker, args.period)
    report = validate_daily_price(price)
    args.output.parent.mkdir(parents=True, exist_ok=True)
    price.to_parquet(args.output)
    print(f"ticker: {ticker}")
    print(f"rows: {report['rows']:,}")
    print(f"date_range: {price.index.min().date()} ~ {price.index.max().date()}")
    print(f"quality: missing={report['missing_core_rows']}, duplicate={report['duplicated_dates']}, ohlc={report['invalid_ohlc_rows']}, volume={report['negative_volume_rows']}")
    print(f"saved: {args.output}")


if __name__ == "__main__":
    main()

실행 예시는 다음과 같습니다.

TEXT
python yfinance_krx_daily_data.py --code 005930 --market KOSPI --period 5y

출력의 quality 항목이 모두 0인지 확인한 뒤에만 저장 파일을 지표 계산이나 백테스트의 입력으로 사용합니다. 하나라도 0이 아니면 코드는 저장 전에 멈추고, 문제가 된 OHLC 거래일을 함께 보여 줍니다. 그 경우 해당 거래일을 제공처·공식 시세와 비교한 뒤 제외·수정·재수집 중 어떤 정책을 쓸지 별도로 기록해야 합니다. 이 예제는 한 종목의 일봉을 다룹니다. 여러 종목을 한 DataFrame에 받을 때는 열이 다중 인덱스로 바뀔 수 있으므로, 종목별 파일로 저장하거나 열 구조를 별도로 설계하는 편이 안전합니다.

5. yfinance 데이터를 어디까지 쓸 수 있는가

yfinance는 연구·학습과 가설 검증에 편리하지만 증권사 체결 데이터나 거래소의 공식 주문 기록을 대체하지는 않습니다. 가격 정정, 제공 지연, 조정 방식의 차이 때문에 같은 규칙의 결과가 실전 환경과 달라질 수 있습니다.

따라서 다음처럼 역할을 나누는 편이 좋습니다.

  • 아이디어·지표 연구: yfinance 일봉으로 빠르게 조건과 신호를 확인합니다.
  • 백테스트 검증: 체결 시점, 비용, 종목군 구성과 데이터 한계를 결과에 함께 기록합니다.
  • 실제 주문 전: 사용할 증권사·거래소 데이터와 신호가 같은지 임의 날짜를 골라 교차 확인합니다.

yfinance 문서도 Yahoo Finance 데이터의 사용 조건을 따로 확인하도록 안내합니다. 데이터를 내려받을 수 있다는 사실과 주문·상업 운용에 그대로 사용할 수 있다는 사실은 다릅니다. yfinance 공식 문서의 사용 범위와 제공처 조건을 함께 확인해야 합니다.

수집 뒤에 이어갈 작업

데이터를 저장했다면 바로 수익률을 계산하기보다 다음 순서로 진행합니다.

  1. 저장한 데이터와 화면·공식 시세를 임의 거래일 몇 개에서 비교합니다.
  2. 신호를 계산할 가격 열과 신호 확정 시점을 규칙표에 적습니다.
  3. 다음 거래일 체결, 비용, 중복 진입 방지를 넣어 거래 내역을 만듭니다.

감으로 찾던 눌림목, 파이썬 자동매매 조건식으로 바꾸는 법은 저장한 가격 데이터를 매매 조건으로 바꾸는 예시입니다. 백테스트 결과를 믿기 전 확인할 3가지은 데이터가 맞아도 체결 시점과 종목군·변수 선택을 다시 점검해야 하는 이유를 다룹니다.


이 글은 데이터 수집과 검증 방법을 설명하기 위한 교육용 자료입니다. 예시는 특정 종목의 매수·매도 추천이나 미래 수익을 의미하지 않으며, 실제 투자 판단과 책임은 투자자 본인에게 있습니다.