본문 바로가기
데이터분석/크롤링

파이썬 퀀트투자(7): 재무제표 크롤링, 적재

by 코듀킹 2024. 10. 12.

주가와 더불어 재무제표와 가치지표 역시 투자에 있어 핵심이 되는 데이터입니다. 이번에는 가치지표를 계산하기 위한 재무제표를 데이터를 크롤링해보겠습니다.

 

 

재무제표 데이터는 여러 웹사이트에서 구할 수 있으며, 국내 데이터 제공업체인 FnGuide에서 운영하는 Company Guide 웹사이트에서 손쉽게 구할 수 있습니다.

 

재무제표 다운로드

사이트를 접속해보면, URL이 아래와 같은 걸 알 수 있습니다. 여기서 A005930 뒤에있는 부분은 제거해도 되는 부분입니다. 

http://comp.fnguide.com/SVO2/ASP/SVD_Finance.asp?pGB=1&gicode=A005930&cID=&MenuYn=Y&ReportGB=&NewMenuID=103&stkGb=701

 

이를 제거한 주소로 접속을 해준 뒤, A뒤의 6자리 티커만 변경한다면 해당 종목의 재무제표 페이지로 이동하게 됩니다.

http://comp.fnguide.com/SVO2/ASP/SVD_Finance.asp?pGB=1&gicode=A005930 

 

사이트를 살펴보면, 우리가 원하는 재무제표 항목들은 모두 테이블 형태로 제공되고 있으므로 pandas 패키지의 read_html() 함수를 이용해 쉽게 추출할 수 있습니다. displayed_only=False를 설정하면, 웹 페이지에 있는 모든 HTML 테이블을 가져오도록 설정합니다. 페이지를 살펴보면, [+] 버튼을 눌러야만 표시되는 항목도 있으므로 설정을 해줍니다. 먼저 삼성전자 종목의 페이지 내용을 불러와보겠습니다.

 

import pandas as pd

url = 'http://comp.fnguide.com/SVO2/ASP/SVD_Finance.asp?pGB=1&gicode=A005930'
data = pd.read_html(url, displayed_only=False)

[item.head(3) for item in data]
[  IFRS(연결)    2021/12    2022/12    2023/12    2024/06       전년동기  전년동기(%)
 0      매출액  2796048.0  3022314.0  2589355.0  1459839.0  1237509.0     18.0
 1     매출원가  1664113.0  1900418.0  1803886.0   901984.0   876543.0      2.9
 2    매출총이익  1131935.0  1121896.0   785469.0   557855.0   360966.0     54.5,
   IFRS(연결)   2023/09   2023/12   2024/03   2024/06      전년동기 전년동기(%)
 0      매출액  674047.0  677799.0  719156.0  740683.0  600055.0    23.4
 1     매출원가  466187.0  461156.0  458863.0  443120.0  416472.0     6.4
 2    매출총이익  207859.0  216644.0  260293.0  297563.0  183583.0    62.1,
              IFRS(연결)    2021/12    2022/12    2023/12    2024/06
 0                  자산  4266212.0  4484245.0  4559060.0  4857577.0
 1  유동자산계산에 참여한 계정 펼치기  2181632.0  2184706.0  1959366.0  2178581.0
 2                재고자산   413844.0   521879.0   516259.0   555666.0,
              IFRS(연결)    2023/09    2023/12    2024/03    2024/06
 0                  자산  4544664.0  4559060.0  4708998.0  4857577.0
 1  유동자산계산에 참여한 계정 펼치기  2064386.0  1959366.0  2085443.0  2178581.0
 2                재고자산   552560.0   516259.0   533477.0   555666.0,
          IFRS(연결)   2021/12   2022/12   2023/12   2024/06
 0    영업활동으로인한현금흐름  651054.0  621813.0  441374.0  287617.0
 1           당기순손익  399075.0  556541.0  154871.0  165961.0
 2  법인세비용차감전계속사업이익       NaN       NaN       NaN       NaN,
          IFRS(연결)  2023/09   2023/12   2024/03   2024/06
 0    영업활동으로인한현금흐름  97305.0  199452.0  118663.0  168954.0
 1           당기순손익  58442.0   63448.0   67547.0   98413.0
 2  법인세비용차감전계속사업이익      NaN       NaN       NaN       NaN]

 

 

크롤링을 통해 총 6개의 테이블이 들어오게 되며, 그 내용은 아래와 같습니다.

순서 내용
0 포괄손익계산서(연간)
1 포괄손익계산서(분기)
2 재무상태표(연간)
3 재무상태표(분기)
4 현금흐름표(연간)
5 현금흐름표(분기)

 

먼저 연간 기준 포괄손익계산서, 재무상태표, 현금흐름표의 열 이름을 살펴보겠습니다.

 

print(data[0].columns.tolist(), '\n',
      data[2].columns.tolist(), '\n',
      data[4].columns.tolist()
     )
['IFRS(연결)', '2021/12', '2022/12', '2023/12', '2024/06', '전년동기', '전년동기(%)'] 
 ['IFRS(연결)', '2021/12', '2022/12', '2023/12', '2024/06'] 
 ['IFRS(연결)', '2021/12', '2022/12', '2023/12', '2024/06']

 

포괄손익계산서 테이블에는 '전년동기', '전년동기(%)' 열이 있으며, 이는 필요하지 않은 내용이므로 삭제해주어야합니다.

  1. 포괄손익계산서 중 '전년동기'라는 글자가 들어간 열을 제외한 데이터를 선택합니다.
  2. concat() 함수를 이용해 포괄손익계산서, 재무상태표, 현금흐름표 세개 테이블을 하나로 묶습니다.
  3. rename() 메서드를 통해 첫번째 열 이름 'IFRS(연결)'을 '계정'으로 변경합니다.

 

data_fs_y = pd.concat(
    [data[0].iloc[:, ~data[0].columns.str.contains('전년동기')], data[2], data[4]])
data_fs_y = data_fs_y.rename(columns={data_fs_y.columns[0]: "계정"})

data_fs_y.head()

 

결산마감 이전에 해당 페이지를 크롤링 할 경우 연간 재무제표 데이터에 분기 재무제표 데이터가 들어오기도 하므로, 연간 재무제표에 해당하는 열 만을 선택해야 합니다. 각 종목 별 결산월은 해당 페이지의 상단에서 확인할 수 있습니다.

 

 

 

이제 해당 데이터를 크롤링 해보도록 하겠습니다.

  1. get() 함수를 통해 페이지의 데이터를 불러온 후, content 부분을 BeautifulSoup 객체로 만듭니다.
  2. 결산월 항목운 [corp_group1 클래스의 div 태그 하부의 h2 태그]에 존재하므로, select() 함수를 이용해 추출합니다.
  3. fiscal_data 중 첫번째는 종목코드에 해당하고, 두번째가 결산 데이터에 해당하므로 해당 부분을 선택해 텍스트만 추출합니다.
  4. 'n월 결산' 형태로 텍스트가 구성되어 있으므로, 정규 표현식을 이용해 숫자에 해당하는 부분만 추출합니다.
  5.  
import requests as rq
from bs4 import BeautifulSoup
import re

page_data = rq.get(url)
page_data_html = BeautifulSoup(page_data.content, 'html.parser')

fiscal_data = page_data_html.select('div.corp_group1 > h2') # [<h2>005930</h2>, <h2>12월 결산</h2>]
fiscal_data_text = fiscal_data[1].text # 12월 결산
fiscal_data_text = re.findall('[0-9]+', fiscal_data_text) # ['12']

 

이를 통해 결산월에 해당하는 부분만이 선택됩니다. 이를 이용해 연간 재무제표에 해당하는 열만 선택해보도록 해보겠습니다. 열 이름이 '계정', 그리고 재무제표의 월이 결산월과 같은 부분만 선택합니다.

 

data_fs_y = data_fs_y.loc[:, (data_fs_y.columns == '계정') |
                          (data_fs_y.columns.str[-2:].isin(fiscal_data_text))]
data_fs_y.head()

 

이제 추가적으로 클렌징해야 하는 사항은 다음과 같습니다. 먼저 재무제표 값 중에서 모든 연도의 데이터가 NaN인 항목이 있습니다. 이는 재무제표 계정은 있으나 해당 종목들은 데이터가 없는 것들이므로 삭제해도 됩니다.

 

data_fs_y[data_fs_y.loc[:, ~data_fs_y.columns.isin(['계정'])].isna().all(
    axis=1)].head()

 

또한 동일한 계정명이 여러번 반복됩니다. 이러한 계정은 대부분 중요하지 않은 것들이므로, 하나만 남겨두도록 합니다.

 

data_fs_y['계정'].value_counts(ascending=False).head()

 

이 외에도 클렌징이 필요한 내용들을 함수로 구성하면 다음과 같습니다.

함수설명

  1. 입력값으로는 데이터프레임, 티커, 공시구분(연간/분기)가 필요합니다.
  2. 먼저 연도의 데이터가 NaN인 항목은 제외합니다.
  3. 계정명이 중복되는 경우 drop_duplicates() 함수를 이용해 첫번째에 위치하는 데이터만 남깁니다.
  4. melt() 함수를 이용해 열로 긴 데이터를 행으로 긴 데이터로 변경한다.
  5. 계정값이 없는 항목은 제외한다.
  6. [계산에 참여한 계정 펼치기]라는 글자는 페이지의 [+]에 해당하는 부분이므로 replace() 메서드를 통해 제거한다.
  7. to_datetime() 메서드를 통해 기준일을 'yyyy-mm' 형태로 바꾼 후, MonthEnd()를 통해 월말에 해당하는 일을 붙인다.
  8. '종목코드' 열에는 티커를 입력한다.
  9. '공시구분' 열에는 연간 혹은 분기에 해당하는 값을 입력한다.

 

def clean_fs(df, ticker, frequency):

    df = df[~df.loc[:, ~df.columns.isin(['계정'])].isna().all(axis=1)]
    df = df.drop_duplicates(['계정'], keep='first')
    df = pd.melt(df, id_vars='계정', var_name='기준일', value_name='값')
    df = df[~pd.isnull(df['값'])]
    df['계정'] = df['계정'].replace({'계산에 참여한 계정 펼치기': ''}, regex=True)
    df['기준일'] = pd.to_datetime(df['기준일'],
                               format='%Y-%m') + pd.tseries.offsets.MonthEnd()
    df['종목코드'] = ticker
    df['공시구분'] = frequency

    return df

 

연간 재무제표 항목에 위 함수를 적용하면 다음과 같은 결과를 확인할 수 있습니다.

 

data_fs_y_clean = clean_fs(data_fs_y, '005930', 'y')

data_fs_y_clean.head()

 

클렌징 처리가 된 데이터가 세로로 긴 형태로 변경되습니다. 이제 분기 재무제표도 클렌징 처리를 해보도록 해보겠습니다. 분기 데이터는 결산월에 해당하는 부분을 선택할 필요가 없으며, 이를 제외하고는 모든 과정이 연간 재무제표를 항목과 동일합니다.

# 분기 데이터

data_fs_q = pd.concat(
    [data[1].iloc[:, ~data[1].columns.str.contains('전년동기')], data[3], data[5]])
data_fs_q = data_fs_q.rename(columns={data_fs_q.columns[0]: "계정"})
data_fs_q_clean = clean_fs(data_fs_q, '005930', 'q')

data_fs_q_clean.head()

 

concat() 함수를 통해 연간 데이터와 분기 데이터 두 테이블을 하나로 합쳐줍니다.

 

 

data_fs_bind = pd.concat([data_fs_y_clean, data_fs_q_clean])

 

전종목 재무제표 크롤링

위 과정을 응용해 모든 종목의 재무제표를 크롤링한 후 DB에 저장하는 과정을 살펴보도록 하겠습니다. 먼저 SQL에서 재무제표가 저장될 테이블(kor_fs)을 만들어줍니다.

 

use stock_db;

create table kor_fs
(
    계정 varchar(30),
    기준일 date,
    값 float,
    종목코드 varchar(6),
    공시구분 varchar(1),
    primary key(계정, 기준일, 종목코드, 공시구분)
)

 

이제 파이썬에서 아래 코드를 실행하면 for문을 통해 전종목 재무제표가 DB에 저장됩니다.

  1. DB에 연결합니다.
  2. 기준일이 최대, 즉 최근일 기준 보통주에 해당하는 티커 리스트(ticker_list)만 불러옵니다.
  3. DB에 저장할 쿼리(query)를 입력합니다.
  4. 오류 발생시 저장할 리스트(error_list)를 만듭니다.
  5. for문을 통해 전종목 재무제표를 다운로드 받으며, 진행상황을 알기위해 tqdm() 함수를 이용합니다.
  6. URL 생성, 데이터 다운로드 및 데이터 클렌징 및 DB에 저장은 위와 동일하며, try except문을 통해 오류가 발생시 티커를 출력 후 error_list에 저장합니다.
  7. 무한 크롤링을 방지하기 위해 한 번의 루프가 끝날 때마다 타임슬립을 적용합니다.
  8. 모든 작업이 끝나면 DB와의 연결을 종료합니다.

 

# 패키지 불러오기
import pymysql
from sqlalchemy import create_engine
import pandas as pd
import requests as rq
from bs4 import BeautifulSoup
import re
from tqdm import tqdm
import time

# DB 연결
engine = create_engine('mysql+pymysql://root:1234@127.0.0.1:3306/stock_db')
con = pymysql.connect(user='root',
                      passwd='1234',
                      host='127.0.0.1',
                      db='stock_db',
                      charset='utf8')
mycursor = con.cursor()

# 티커리스트 불러오기
ticker_list = pd.read_sql("""
select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker) 
	and 종목구분 = '보통주';
""", con=engine)

# DB 저장 쿼리
query = """
    insert into kor_fs (계정, 기준일, 값, 종목코드, 공시구분)
    values (%s,%s,%s,%s,%s) as new
    on duplicate key update
    값=new.값
"""

# 오류 발생시 저장할 리스트 생성
error_list = []


# 재무제표 클렌징 함수
def clean_fs(df, ticker, frequency):

    df = df[~df.loc[:, ~df.columns.isin(['계정'])].isna().all(axis=1)]
    df = df.drop_duplicates(['계정'], keep='first')
    df = pd.melt(df, id_vars='계정', var_name='기준일', value_name='값')
    df = df[~pd.isnull(df['값'])]
    df['계정'] = df['계정'].replace({'계산에 참여한 계정 펼치기': ''}, regex=True)
    df['기준일'] = pd.to_datetime(df['기준일'],
                               format='%Y-%m') + pd.tseries.offsets.MonthEnd()
    df['종목코드'] = ticker
    df['공시구분'] = frequency

    return df


# for loop
for i in tqdm(range(0, len(ticker_list))):

    # 티커 선택
    ticker = ticker_list['종목코드'][i]

    # 오류 발생 시 이를 무시하고 다음 루프로 진행
    try:

        # url 생성
        url = f'http://comp.fnguide.com/SVO2/ASP/SVD_Finance.asp?pGB=1&gicode=A{ticker}'

        # 데이터 받아오기
        data = pd.read_html(url, displayed_only=False)

        # 연간 데이터
        data_fs_y = pd.concat([
            data[0].iloc[:, ~data[0].columns.str.contains('전년동기')], data[2],
            data[4]
        ])
        data_fs_y = data_fs_y.rename(columns={data_fs_y.columns[0]: "계정"})

        # 결산년 찾기
        page_data = rq.get(url)
        page_data_html = BeautifulSoup(page_data.content, 'html.parser')

        fiscal_data = page_data_html.select('div.corp_group1 > h2')
        fiscal_data_text = fiscal_data[1].text
        fiscal_data_text = re.findall('[0-9]+', fiscal_data_text)

        # 결산년에 해당하는 계정만 남기기
        data_fs_y = data_fs_y.loc[:, (data_fs_y.columns == '계정') | (
            data_fs_y.columns.str[-2:].isin(fiscal_data_text))]

        # 클렌징
        data_fs_y_clean = clean_fs(data_fs_y, ticker, 'y')

        # 분기 데이터
        data_fs_q = pd.concat([
            data[1].iloc[:, ~data[1].columns.str.contains('전년동기')], data[3],
            data[5]
        ])
        data_fs_q = data_fs_q.rename(columns={data_fs_q.columns[0]: "계정"})

        data_fs_q_clean = clean_fs(data_fs_q, ticker, 'q')

        # 두개 합치기
        data_fs_bind = pd.concat([data_fs_y_clean, data_fs_q_clean])

        # 재무제표 데이터를 DB에 저장
        args = data_fs_bind.values.tolist()
        mycursor.executemany(query, args)
        con.commit()

    except:

        # 오류 발생시 해당 종목명을 저장하고 다음 루프로 이동
        print(ticker)
        error_list.append(ticker)

    # 타임슬립 적용
    time.sleep(2)

# DB 연결 종료
engine.dispose()
con.close()

 

 

작업이 끝난 후 SQL의 kor_fs 테이블을 확인해보면 전 종목의 재무제표가 저장되어 있습니다. 시간이 지나 위 코드를 다시 실행하면 upsert 형식을 통해 수정된 재무제표는 update를, 새로 입력된 재무제표는 insert를 합니다.

 

참고자료

https://github.com/hyunyulhenry/quant_py/blob/main/data_korea.ipynb

 

댓글