KevinFire2030 / Fire2025

0 stars 0 forks source link

10장 국내 주식 데이터 수집 #22

Open KevinFire2030 opened 1 year ago

KevinFire2030 commented 1 year ago

이번 장에서는 극내 주식 데이터 중 주식티커와 섹터별 구성종목 및 퀀트 투자를 위한 핵심 데이터인 수정주가, 재무제표, 가치지표를 크롤링하는 방법을 알아보겠다.

KevinFire2030 commented 1 year ago

10.1 최근 영업일 기준 데이터 받기

POST 방식으로 금융 데이터를 제공하는 일부 사이트에서는 쿼리 항목에 특정 날짜를 입력하면(예:20221230) 해당일의 데이터를 다운로드 할 수 있으며, 최근 영업일 날짜를 입력하면 가장 최근의 데이터를 받을 수 있다. 따라서 최근 영업일에 해당하는 항목을 매번 수기로 입력하기 보다는 자동으로 반영되게 할 필요가 있다.

네이버 금융의 [국내증시 → 증시자금동향]에는 이전 2영업일에 해당하는 날짜가 있으며, 자동으로 날짜가 업데이트된다. 따라서 해당 부분을 크롤링 한후 날짜에 해당하는 쿼리 항목에 사용하면 된다.

https://finance.naver.com/sise/sise_deposit.nhn

개발자도구 화면을 이용해 해당 데이터가 있는 부분을 확인해보면 [클래스가 subtop_sise_graph2인 div 태그 → 클래스가 subtop_chart_note인 ul 태그 → li 태그 → 클래스가 tah인 span 태그]에 위치해 있다는 걸 알 수 있다. 이를 이용해 해당 데이터를 크롤링한다.


import requests as rq
from bs4 import BeautifulSoup

url = 'https://finance.naver.com/sise/sise_deposit.nhn'
data = rq.get(url)
data_html = BeautifulSoup(data.content)
parse_day = data_html.select_one(
    'div.subtop_sise_graph2 > ul.subtop_chart_note > li > span.tah').text

print(parse_day)

>> |  2023.06.21

import re

biz_day = re.findall('[0-9]+', parse_day)
biz_day = ''.join(biz_day)

print(biz_day)

>> 20220803

image

KevinFire2030 commented 1 year ago

10.2 한국거래소의 업종분류 현황 및 개별지표 크롤링

주식 관련 데이터를 구하기 위해 가장 먼저 해야하는 일은 어떠한 종목들이 상장되어 있는가에 대한 정보를 구하는 것이다. 한국거래소에서 제공하는 업종분류 현황과 개별종목 지표 데이터를 이용하면 매우 간단하게 해당 정보를 수집할 수 있다.

해당 데이터들을 크롤링이 아닌 [Excel] 버튼을 클릭해 엑셀 파일로 받을 수도 있다. 그러나 매번 엑셀 파일을 다운로드하고 이를 불러오는 작업은 상당히 비효율적이며, 크롤링을 이용한다면 해당 데이터를 파이썬에서 바로 불러올 수 있다.

10.2.1 업종분류 현황 크롤링

먼저 업종분류 현황에 해당하는 페이지에 접속하여 F12를 눌러 개발자도구 화면을 열고 [다운로드] 버튼을 클릭한 후 [CSV]를 누른다. 개발자도구 화면의 [Network] 탭에는 generate.cmd와 download.cmd 두 가지 항목이 생긴다.

거래소에서 엑셀 혹은 CSV 데이터를 받는 과정은 다음과 같다.

  1. http://data.krx.co.kr/comm/fileDn/download_excel/download.cmd 에 원하는 항목을 쿼리로 발송하면 해당 쿼리에 해당하는 OTP(generate.cmd)를 받는다.
  2. 부여받은 OTP를 http://data.krx.co.kr/ 에 제출하면 이에 해당하는 데이터(download.cmd)를 다운로드한다.

import requests as rq
from bs4 import BeautifulSoup

url = 'https://finance.naver.com/sise/sise_deposit.nhn'
data = rq.get(url)
data_html = BeautifulSoup(data.content)
parse_day = data_html.select_one(
    'div.subtop_sise_graph2 > ul.subtop_chart_note > li > span.tah').text

import re

biz_day = re.findall('[0-9]+', parse_day)
biz_day = ''.join(biz_day)

print(biz_day)

import requests as rq
from io import BytesIO
import pandas as pd

gen_otp_url = 'http://data.krx.co.kr/comm/fileDn/GenerateOTP/generate.cmd'

gen_otp_stk = {
    'mktId': 'STK',
    'trdDd': biz_day,
    'money': '1',
    'csvxls_isNo': 'false',
    'name': 'fileDown',
    'url': 'dbms/MDC/STAT/standard/MDCSTAT03901'
}
headers = {'Referer': 'http://data.krx.co.kr/contents/MDC/MDI/mdiLoader'}
otp_stk = rq.post(gen_otp_url, gen_otp_stk, headers=headers).text

print(otp_stk)

down_url = 'http://data.krx.co.kr/comm/fileDn/download_csv/download.cmd'
down_sector_stk = rq.post(down_url, {'code': otp_stk}, headers=headers)
sector_stk = pd.read_csv(BytesIO(down_sector_stk.content), encoding='EUC-KR')

print(sector_stk.head())

gen_otp_ksq = {
    'mktId': 'KSQ',  # 코스닥 입력
    'trdDd': biz_day,
    'money': '1',
    'csvxls_isNo': 'false',
    'name': 'fileDown',
    'url': 'dbms/MDC/STAT/standard/MDCSTAT03901'
}
otp_ksq = rq.post(gen_otp_url, gen_otp_ksq, headers=headers).text

down_sector_ksq = rq.post(down_url, {'code': otp_ksq}, headers=headers)
sector_ksq = pd.read_csv(BytesIO(down_sector_ksq.content), encoding='EUC-KR')

print(sector_ksq.head())

krx_sector = pd.concat([sector_stk, sector_ksq]).reset_index(drop=True)
krx_sector['종목명'] = krx_sector['종목명'].str.strip()
krx_sector['기준일'] = biz_day

print(krx_sector.head())

image

10.2.2 개별종목 지표 크롤링

개별종목 데이터를 크롤링하는 방법은 위와 매우 유사하며, 요청하는 쿼리 값에만 차이가 있다. 개발자도구 화면을 열고 [CSV] 버튼을 클릭해 어떠한 쿼리를 요청하는지 확인해보자.

OTP를 생성하는 부분인 [generate.cmd]를 확인해보면 [Payload] 탭의 'tboxisuCd_finder_stkisu0_6', 'isu_Cd', 'isu_Cd2' 등의 항목은 조회 구분의 개별추이 탭에 해당하는 부분이므로 우리가 원하는 전체 데이터를 받을 때는 필요하지 않은 요청값다. 이를 제외한 요청값을 산업별 현황 예제에 적용하면 해당 데이터 역시 손쉽게 다운로드할 수 있다.


import requests as rq
from io import BytesIO
import pandas as pd

gen_otp_url = 'http://data.krx.co.kr/comm/fileDn/GenerateOTP/generate.cmd'
gen_otp_data = {
    'searchType': '1',
    'mktId': 'ALL',
    'trdDd': biz_day,
    'csvxls_isNo': 'false',
    'name': 'fileDown',
    'url': 'dbms/MDC/STAT/standard/MDCSTAT03501'
}
headers = {'Referer': 'http://data.krx.co.kr/contents/MDC/MDI/mdiLoader'}
otp = rq.post(gen_otp_url, gen_otp_data, headers=headers).text

down_url = 'http://data.krx.co.kr/comm/fileDn/download_csv/download.cmd'
krx_ind = rq.post(down_url, {'code': otp}, headers=headers)

krx_ind = pd.read_csv(BytesIO(krx_ind.content), encoding='EUC-KR')
krx_ind['종목명'] = krx_ind['종목명'].str.strip()
krx_ind['기준일'] = biz_day

krx_ind.head()

10.2.3 데이터 정리하기

먼저 두 데이터에 공통으로 존재하지 않는 종목, 즉 하나의 데이터에만 존재하는 종목을 살펴보도록 하자.


import requests as rq
from io import BytesIO
import pandas as pd
from bs4 import BeautifulSoup

url = 'https://finance.naver.com/sise/sise_deposit.nhn'
data = rq.get(url)
data_html = BeautifulSoup(data.content)
parse_day = data_html.select_one(
    'div.subtop_sise_graph2 > ul.subtop_chart_note > li > span.tah').text

import re

biz_day = re.findall('[0-9]+', parse_day)
biz_day = ''.join(biz_day)

biz_day = '20230624'

#업종분류 현황 크롤링

# 코스피
gen_otp_url = 'http://data.krx.co.kr/comm/fileDn/GenerateOTP/generate.cmd'

gen_otp_stk = {
    'mktId': 'STK',
    'trdDd': biz_day,
    'money': '1',
    'csvxls_isNo': 'false',
    'name': 'fileDown',
    'url': 'dbms/MDC/STAT/standard/MDCSTAT03901'
}
headers = {'Referer': 'http://data.krx.co.kr/contents/MDC/MDI/mdiLoader'}
otp_stk = rq.post(gen_otp_url, gen_otp_stk, headers=headers).text

#print(otp_stk)

down_url = 'http://data.krx.co.kr/comm/fileDn/download_csv/download.cmd'
down_sector_stk = rq.post(down_url, {'code': otp_stk}, headers=headers)
sector_stk = pd.read_csv(BytesIO(down_sector_stk.content), encoding='EUC-KR')

#print(sector_stk.head())

# 코스닥
gen_otp_ksq = {
    'mktId': 'KSQ',  # 코스닥 입력
    'trdDd': biz_day,
    'money': '1',
    'csvxls_isNo': 'false',
    'name': 'fileDown',
    'url': 'dbms/MDC/STAT/standard/MDCSTAT03901'
}
otp_ksq = rq.post(gen_otp_url, gen_otp_ksq, headers=headers).text

down_sector_ksq = rq.post(down_url, {'code': otp_ksq}, headers=headers)
sector_ksq = pd.read_csv(BytesIO(down_sector_ksq.content), encoding='EUC-KR')

#print(sector_ksq.head())

krx_sector = pd.concat([sector_stk, sector_ksq]).reset_index(drop=True)
krx_sector['종목명'] = krx_sector['종목명'].str.strip()
krx_sector['기준일'] = biz_day

#개별종목 지표 크롤링

gen_otp_url = 'http://data.krx.co.kr/comm/fileDn/GenerateOTP/generate.cmd'
gen_otp_data = {
    'searchType': '1',
    'mktId': 'ALL',
    'trdDd': biz_day,
    'csvxls_isNo': 'false',
    'name': 'fileDown',
    'url': 'dbms/MDC/STAT/standard/MDCSTAT03501'
}
headers = {'Referer': 'http://data.krx.co.kr/contents/MDC/MDI/mdiLoader'}
otp = rq.post(gen_otp_url, gen_otp_data, headers=headers).text

down_url = 'http://data.krx.co.kr/comm/fileDn/download_csv/download.cmd'
krx_ind = rq.post(down_url, {'code': otp}, headers=headers)

krx_ind = pd.read_csv(BytesIO(krx_ind.content), encoding='EUC-KR')
krx_ind['종목명'] = krx_ind['종목명'].str.strip()
krx_ind['기준일'] = biz_day

#print(krx_ind)

# 데이터 정리하기

diff = list(set(krx_sector['종목명']).symmetric_difference(set(krx_ind['종목명'])))
#print(diff)

kor_ticker = pd.merge(krx_sector,
                      krx_ind,
                      on=krx_sector.columns.intersection(
                          krx_ind.columns).tolist(),
                      how='outer')

import numpy as np

kor_ticker['종목구분'] = np.where(kor_ticker['종목명'].str.contains('스팩|제[0-9]+호'), '스팩',
                              np.where(kor_ticker['종목코드'].str[-1:] != '0', '우선주',
                                       np.where(kor_ticker['종목명'].str.endswith('리츠'), '리츠',
                                                np.where(kor_ticker['종목명'].isin(diff),  '기타',
                                                '보통주'))))
kor_ticker = kor_ticker.reset_index(drop=True)
kor_ticker.columns = kor_ticker.columns.str.replace(' ', '')
kor_ticker = kor_ticker[['종목코드', '종목명', '시장구분', '종가',
                         '시가총액', '기준일', 'EPS', '선행EPS', 'BPS', '주당배당금', '종목구분']]
kor_ticker = kor_ticker.replace({np.nan: None})
kor_ticker['기준일'] = pd.to_datetime(kor_ticker['기준일'])

print(kor_ticker)

# DB에 저장하기
import sqlite3

con = sqlite3.connect("kor_ticker.db")
kor_ticker.to_sql('kor_ticker', con, if_exists='replace')

image

KevinFire2030 commented 1 year ago

10.3 WICS 기준 섹터정보 크롤링

일반적으로 주식의 섹터를 나누는 기준은 MSCI와 S&P가 개발한 GICS를 가장 많이 사용한다. 국내 종목의 GICS 기준 정보 역시 한국거래소에서 제공하고 있으나, 이는 독점적 지적재산으로 명시했기에 사용하는데 무리가 있다. 그러나 지수제공업체인 FnGuide Index에서는 GICS와 비슷한 WICS 산업분류를 발표하고 있다. WICS를 크롤링하여 필요한 정보를 수집해보도록 하자.

http://www.wiseindex.com/Index

먼저 웹페이지에 접속해 왼쪽에서 [WISE SECTOR INDEX → WICS → 에너지]를 클릭한다. 그 후 [Components] 탭을 클릭하면 해당 섹터의 구성종목을 확인할 수 있다.

개발자도구 화면({numref}wise_index_2)을 통해 해당 페이지의 데이터전송 과정을 살펴보자. 일자를 선택하면 [Network] 탭의 'GetIndexComponets' 항목에 데이터 전송 과정이 나타난다. Request URL은 데이터를 가져오는 주소이며, 이를 분석하면 다음과 같다.

http://www.wiseindex.com/Index/GetIndexComponets?ceil_yn=0&dt=20210210&sec_cd=G10

  1. http://www.wiseindex.com/Index/GetIndexComponets: 데이터를 요청하는 url이다.
  2. ceil_yn = 0: 실링 여부를 나타내며, 0은 비실링을 의미한다.
  3. dt=20220419: 조회일자를 나타낸다.
  4. sec_cd=G10: 섹터 코드를 나타낸다.

image

image

이번에는 Request URL에 해당하는 페이지를 열어보자.

페이지에 출력된 글자들이 보이지만 매우 특이한 형태로 구성되어 있으며, 이는 JSON 형식의 데이터다. 기존에 우리가 살펴보았던 대부분의 웹페이지는 HTML 형식으로 표현되었다. HTML 형식은 문법이 복잡하고 표현 규칙이 엄격해 데이터의 용량이 커지는 단점이 있다. 반면 JSON 형식은 문법이 단순하고 데이터의 용량이 작아 빠른 속도로 데이터를 교환할 수 있다. 파이썬에서는 json 패키지를 사용해 매우 손쉽게 JSON 형식의 데이터를 크롤링할 수 있다.

XML과 JSON의 특징, 공통점, 차이점


import json
import requests as rq
import pandas as pd

biz_day = '20230623'

url = f'''http://www.wiseindex.com/Index/GetIndexComponets?ceil_yn=0&dt={biz_day}&sec_cd=G10'''
data = rq.get(url).json()

print(data)
  1. f-string 포매팅을 이용해 dt 부분에는 위에서 구한 최근 영업일 데이터를 입력하여 URL을 생성한다.
  2. get() 함수를 통해 페이지의 내용을 받아오며, json() 메서드를 통해 JSON 데이터만 불러올 수 있다.

pandas 패키지의 json_normalize() 함수를 이용하면 JSON 형태의 데이터를 데이터프레임 형태로 매우 쉽게 변경할 수 있다. 이제 for문을 이용하여 URL의 sec_cd=에 해당하는 부분만 변경하면 모든 섹터의 구성종목을 매우 쉽게 얻을 수 있다.

import time
import json
import requests as rq
import pandas as pd
from tqdm import tqdm

sector_code = [
    'G25', 'G35', 'G50', 'G40', 'G10', 'G20', 'G55', 'G30', 'G15', 'G45'
]

data_sector = []

for i in tqdm(sector_code):
    url = f'''http://www.wiseindex.com/Index/GetIndexComponets?ceil_yn=0&dt={biz_day}&sec_cd={i}'''    
    data = rq.get(url).json()
    data_pd = pd.json_normalize(data['list'])

    data_sector.append(data_pd)

    time.sleep(2)

kor_sector = pd.concat(data_sector, axis = 0)
kor_sector = kor_sector[['IDX_CD', 'CMP_CD', 'CMP_KOR', 'SEC_NM_KOR']]
kor_sector['기준일'] = biz_day
kor_sector['기준일'] = pd.to_datetime(kor_sector['기준일'])
  1. 섹터 정보가 들어갈 빈 리스트(data_sector)를 만든다.
  2. for문의 i에 섹터 코드를 입력하여 모든 섹터의 구성종목을 다운로드 받은 후 append() 메서드를 통해 리스트에 추가한다.
  3. tqdm() 함수를 통해 진행상황을 출력한다.

image

  1. concat() 함수를 이용해 리스트 내의 데이터프레임을 합친다.
  2. 필요한 열(섹터 코드, 티커, 종목명, 섹터명)만 선택한다.
  3. 데이터의 기준일에 해당하는 [기준일] 열을 추가한 후 datetime 형태로 변경한다.
KevinFire2030 commented 1 year ago

10.4 수정주가 크롤링

주가 데이터는 투자를 함에 있어 반드시 필요한 데이터이며, 인터넷에서 주가를 수집할 수 있는 방법은 매우 많다. 그러나 일반적인 주가를 구할 수 있는 방법은 많지만, 퀀트 투자를 위한 백테스트나 종목선정을 위해서는 수정주가가 필요하다. 수정주가가 필요한 이유를 알아보기 위해 실제 사례를 살펴보도록 하자. 삼성전자는 2018년 5월 기존의 1주를 50주로 나누는 액면분할을 실시했고, 265만 원이던 주가는 다음날 50분의 1인 5만 3000원으로 거래되었다. 이러한 이벤트를 고려하지 않고 주가만 살펴본다면 마치 -98% 수익률을 기록한 것 같지만, 투자자 입장에서는 1주이던 주식이 50주로 늘어났기 때문에 자산에는 아무런 변화가 없다. 이를 고려하는 방법은 액면분할 전 모든 주가를 50으로 나누어 연속성을 갖게 만드는 것이며, 이를 '수정주가'라고 한다. 따라서 백테스트 혹은 퀀트 지표 계산에는 수정주가가 사용되어야 하며, 네이버 금융에서 제공하는 정보를 통해 모든 종목의 수정주가를 손쉽게 구할 수 있다.

10.4.1 개별종목 주가 크롤링

먼저 네이버 금융에서 특정종목(예: 삼성전자)의 [차트]탭을 선택한다.

https://finance.naver.com/item/fchart.nhn?code=005930 해당 차트는 주가 데이터를 받아와 화면에 그래프를 그려주는 형태다. 따라서 데이터가 어디에서 오는지 알기 위해 개발자도구 화면을 이용한다. 화면을 연 상태에서 [일]을 선택하면 나오는 항목 중 가장 상단 항목 [siseJson.naver?symbol=..]의 Request URL이 주가 데이터를 요청하는 주소다.

URL은 다음과 같다.

https://api.finance.naver.com/siseJson.naver?symbol=005930&requestType=1&startTime=20200214&endTime=20220422&timeframe=day 위 페이지에 접속하면 {numref}naver_stock와 같이 날짜별로 시가, 고가, 저가, 종가, 거래량, 외국인소진율이 있으며, 주가는 모두 수정주가 기준이다. URL에서 'symbol=' 뒤에 6자리 티커만 변경하면 해당 종목의 주가 데이터가 있는 페이지로 이동할 수 있으며, 이를 통해 우리가 원하는 모든 종목의 수정주가 데이터를 크롤링할 수 있다. 또한 'startTime=' 에는 시작일자를, 'endTime=' 에는 종료일자를 입력하여 원하는 기간 만큼의 데이터를 받을 수도 있다.


from dateutil.relativedelta import relativedelta
import requests as rq
from io import BytesIO
from datetime import date

i = 0
ticker = ticker_list['종목코드'][i]
fr = (date.today() + relativedelta(years=-5)).strftime("%Y%m%d")
to = (date.today()).strftime("%Y%m%d")

url = f'''https://fchart.stock.naver.com/siseJson.nhn?symbol={ticker}&requestType=1
&startTime={fr}&endTime={to}&timeframe=day'''

data = rq.get(url).content
data_price = pd.read_csv(BytesIO(data))

data_price.head()

# 패키지 불러오기
import pandas as pd
from datetime import date
from dateutil.relativedelta import relativedelta
import requests as rq
import time
from tqdm import tqdm
from io import BytesIO
import sqlite3

# 티커리스트 불러오기

con = sqlite3.connect("kor_ticker.db")
con2 = sqlite3.connect("kor_price.db")

ticker_list = pd.read_sql("""
select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker) 
    and 종목구분 = '보통주';
""", con)

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

# 전종목 주가 다운로드 및 저장
for i in tqdm(range(0, len(ticker_list))):

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

    # 시작일과 종료일
    fr = (date.today() + relativedelta(years=-5)).strftime("%Y%m%d")
    to = (date.today()).strftime("%Y%m%d")

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

        # url 생성
        url = f'''https://fchart.stock.naver.com/siseJson.nhn?symbol={ticker}&requestType=1
        &startTime={fr}&endTime={to}&timeframe=day'''

        # 데이터 다운로드
        data = rq.get(url).content
        data_price = pd.read_csv(BytesIO(data))

        # 데이터 클렌징
        price = data_price.iloc[:, 0:6]
        price.columns = ['날짜', '시가', '고가', '저가', '종가', '거래량']
        price = price.dropna()
        price['날짜'] = price['날짜'].str.extract('(\d+)')
        price['날짜'] = pd.to_datetime(price['날짜'])
        price['종목코드'] = ticker

        # 주가 데이터를 DB에 저장

        """
        args = price.values.tolist()
        mycursor.executemany(query, args)
        con.commit()
        """

        price.to_sql(ticker, con2, if_exists='replace')

    except:

        # 오류 발생시 error_list에 티커 저장하고 넘어가기
        print(ticker)
        error_list.append(ticker)

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

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

100%|██████████| 2352/2352 [46:00<00:00, 1.17s/it]

KevinFire2030 commented 1 year ago

10.5 재무제표 크롤링

주가와 더불어 재무제표와 가치지표 역시 투자에 있어 핵심이 되는 데이터다. 해당 데이터는 여러 웹사이트에서 구할 수 있으며, 국내 데이터 제공업체인 FnGuide에서 운영하는 Company Guide 웹사이트에서 손쉽게 구할 수 있다.

http://comp.fnguide.com/

10.5.1 재무제표 다운로드

먼저 웹사이트에서 개별종목의 재무제표를 탭을 선택하면 포괄손익계산서, 재무상태표, 현금흐름표 항목이 있으며, 티커에 해당하는 A005930 뒤의 주소는 불필요한 내용이므로, 이를 제거한 주소로 접속한다. A 뒤의 6자리 티커만 변경한다면 해당 종목의 재무제표 페이지로 이동하게 된다.

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

image

우리가 원하는 재무제표 항목들은 모두 테이블 형태로 제공되고 있으므로 pandas 패키지의 read_html() 함수를 이용해 쉽게 추출할 수 있다. 먼저 삼성전자 종목의 페이지 내용을 불러오자.

from sqlalchemy import create_engine
import pandas as pd

engine = create_engine('mysql+pymysql://root:1234@127.0.0.1:3306/stock_db')
query = """
select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker) 
    and 종목구분 = '보통주';
"""
ticker_list = pd.read_sql(query, con=engine)
engine.dispose()

i = 0
ticker = ticker_list['종목코드'][i]

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

[item.head(3) for item in data]
순서 내용
0 포괄손익계산서 (연간)
1 포괄손익계산서 (분기)
2 재무상태표 (연간)
3 재무상태표 (분기)
4 현금흐름표 (연간)
5 현금흐름표 (분기)

import pandas as pd
import requests as rq
from bs4 import BeautifulSoup
import re
from tqdm import tqdm
import time
import sqlite3

con = sqlite3.connect("kor_ticker.db")
query = """ select * from kor_ticker where 기준일 = (select max(기준일) from kor_ticker) and 종목구분 = '보통주'"""

ticker_list = pd.read_sql(query, con)

# 오류 발생시 저장할 리스트 생성
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()

        con = sqlite3.connect("data_fs.db")
        data_fs_bind.to_sql('data_fs', con, if_exists='replace')

    except:

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

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

print('ok')
KevinFire2030 commented 1 year ago

10.6 가치지표 계산

위에서 구한 재무제표 데이터를 이용해 가치지표를 계산할 수 있다. 흔히 가치지표로는 'PER', 'PBR', 'PCR', 'PSR', 'DY'가 사용된다.

지표 설명 필요한 재무제표 데이터
PER Price to Earnings Ratio Earnings (순이익)
PBR Price to Book Ratio Book Value (순자산)
PCR Price to Cash Flow Ratio Cash Flow (영업활동현금흐름)
PSR Price to Sales Ratio Sales (매출액)
DY Dividend Yield Dividened (배당)

가치지표의 경우 연간 재무제표 기준으로 계산할 경우 다음 재무제표가 발표될 때까지 1년이나 기다려야 한다. 반면 분기 재무제표는 3개월 마다 발표되므로 최신 정보를 훨씬 빠르게 반영할 수 있다는 장점이 있으므로 일반적으로 최근 4분기 데이터를 이용해 계산하는 TTM(Trailing Twelve Months) 방법을 많이 사용한다. 먼저 예제로 삼성전자의 가치지표를 계산해보도록 하자.


# 패키지 불러오기

import pandas as pd
import numpy as np
import sqlite3

# DB 연결
con = sqlite3.connect("kor_ticker.db")
con2 = sqlite3.connect("kor_fs.db")

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

# 분기 재무제표 불러오기
kor_fs = pd.read_sql("""
select * from kor_fs
where 공시구분 = 'q'
and 계정 in ('당기순이익', '자본', '영업활동으로인한현금흐름', '매출액');
""", con2)

# TTM 구하기
kor_fs = kor_fs.sort_values(['종목코드', '계정', '기준일'])
kor_fs['ttm'] = kor_fs.groupby(['종목코드', '계정'], as_index=False)['값'].rolling(
    window=4, min_periods=4).sum()['값']

# 자본은 평균 구하기
kor_fs['ttm'] = np.where(kor_fs['계정'] == '자본', kor_fs['ttm'] / 4,
                         kor_fs['ttm'])
kor_fs = kor_fs.groupby(['계정', '종목코드']).tail(1)

kor_fs_merge = kor_fs[['계정', '종목코드',
                       'ttm']].merge(ticker_list[['종목코드', '시가총액', '기준일']],
                                     on='종목코드')
kor_fs_merge['시가총액'] = kor_fs_merge['시가총액'].astype(float) / 100000000

kor_fs_merge['value'] = kor_fs_merge['시가총액'] / kor_fs_merge['ttm']
kor_fs_merge['value'] = kor_fs_merge['value'].round(4)
kor_fs_merge['지표'] = np.where(
    kor_fs_merge['계정'] == '매출액', 'PSR',
    np.where(
        kor_fs_merge['계정'] == '영업활동으로인한현금흐름', 'PCR',
        np.where(kor_fs_merge['계정'] == '자본', 'PBR',
                 np.where(kor_fs_merge['계정'] == '당기순이익', 'PER', None))))

kor_fs_merge.rename(columns={'value': '값'}, inplace=True)
kor_fs_merge = kor_fs_merge[['종목코드', '기준일', '지표', '값']]
kor_fs_merge = kor_fs_merge.replace([np.inf, -np.inf, np.nan], None)

# DB에 자장하기
con = sqlite3.connect("stock.db")
kor_fs_merge.to_sql('kor_value', con, if_exists='replace')

# 배당수익률 역시 kor_value 테이블에 upsert 방식으로 저장한 후, DB와의 연결을 종료한다.
ticker_list['값'] = ticker_list['주당배당금'].astype(float) / ticker_list['종가'].astype(float)
ticker_list['값'] = ticker_list['값'].round(4)
ticker_list['지표'] = 'DY'
dy_list = ticker_list[['종목코드', '기준일', '지표', '값']]
dy_list = dy_list.replace([np.inf, -np.inf, np.nan], None)
dy_list = dy_list[dy_list['값'] != 0]

dy_list.to_sql('kor_value', con, if_exists='append')

print('ok')
KevinFire2030 commented 1 year ago

[Pandas] pd.read_html() :: html에서 표 가져오기/데이터프레임으로 만들기

https://mizykk.tistory.com/40

pandas.read_html(URL, match='.+', flavor=None, header=None, index_col=None, skiprows=None, attrs=None, parse_dates=False, tupleize_cols=None, thousands=', ', encoding=None, decimal='.', converters=None, na_values=None, keep_default_na=True, displayed_only=True)

• URL : 대상 url 입력 • match : str or compiled regular expression, optional : 정규표현식 또는 문자열을 이용해서 전체 테이블을 가져오지말고 원하는 내용이 들어있는 테이블만 가져오게 함. • flavor = None / ‘bs4’ / ‘html5lib’ : html을 parsing할 engine 선택. None일 경우 'lxml'으로 시도된 후, 실패하면 bs4 + html5lib으로 수행된다. • header = int or list-like or None, optional : header로(열 이름) 쓸 행을 지정할 수 있다. • encoding = str or None, optional : 인코딩 설정. 한글이 깨져서 나올 때 encoding = 'utf-8'으로 설정하면 된다.

예제 URL : https://mizykk.tistory.com/39 예제 페이지에는 3개의 표가 있다.

과일 색상 가격
사과 빨강 1500원
사과 초록 1000원
바나나 노랑 3000원
바나나 초록 2000원
국가 도시 언어
한국 서울 한국어
미국 뉴욕 영어
이탈리아 피렌체 이탈리아어
프랑스 파리 불어
한국어 영어
1월 January
2월 Feburary
3월 March

1. html에서 표가져오기

table = pd.read_html('https://mizykk.tistory.com/39', header=0, encoding='utf-8')
table

→ [데이터프레임(표1), 데이터프레임(표2), 데이터프레임(표3)]

pd.read_html()을 사용하면 리스트 안에 데이터프레임이 들어있는 형태로 html에 있는 모든 table을 한번에 가져온다. 따라서 인덱스를 이용하여 원하는 표를 개별 데이터프레임으로 지정해주면 된다.

table[1]

image

  국가   도시     언어

0 한국 서울 한국어 1 미국 뉴욕 영어 2 이탈리아 피렌체 이탈리아어 3 프랑스 파리 불어

2. match : 원하는 내용이 들어있는 표만 가져오기

: 정규표현식 또는 문자열을 이용해서 원하는 내용이 들어있는 테이블만 가져오게 함.

table2 = pd.read_html('https://mizykk.tistory.com/39', `match = '국가'`, header=0, encoding='utf-8')
table2

match = '국가'를 지정하여 국가가 포함된 테이블만 불러오도록 하였다. 해당 페이지에는 테이블이 3개지만 '국가'가 포함된 테이블은 하나라 하나의 표만 불러와졌다.

html에서 하나의 표만 불러왔더라도 리스트안에 데이터프레임이 들어있는 형태로 나타난다. 따라서 table을 데이터프레임으로 활용하려면 무조건 인덱스로 불러주어야한다.

table2[0]

KevinFire2030 commented 1 year ago

data_fs_bind.to_sql('data_fs', con, if_exists='replace')

100%|██████████| 2352/2352 [2:19:27<00:00, 3.56s/it]


# 다운받은 엑셀 삭제(크롤링해당)
try:
    df.to_sql(name='test', con=db_connection, if_exists='append',index=False)  
    os.remove(xlsFiles[-1])
except:
    os.remove(xlsFiles[-1])
KevinFire2030 commented 1 year ago
Traceback (most recent call last):
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\base.py", line 1325, in _arith_method
    result = ops.arithmetic_op(lvalues, rvalues, op)
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\ops\array_ops.py", line 226, in arithmetic_op
    res_values = _na_arithmetic_op(left, right, op)  # type: ignore[arg-type]
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\ops\array_ops.py", line 165, in _na_arithmetic_op
    result = func(left, right)
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\computation\expressions.py", line 241, in evaluate
    return _evaluate(op, op_str, a, b)  # type: ignore[misc]
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\computation\expressions.py", line 70, in _evaluate_standard

image

기준일 변경 ('20230624' → '20230623')

biz_day = '20230623'

KevinFire2030 commented 1 year ago

TypeError: unsupported operand type(s) for /: 'str' and 'int'

Traceback (most recent call last):
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\base.py", line 1325, in _arith_method
    result = ops.arithmetic_op(lvalues, rvalues, op)
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\ops\array_ops.py", line 226, in arithmetic_op
    res_values = _na_arithmetic_op(left, right, op)  # type: ignore[arg-type]
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\ops\array_ops.py", line 172, in _na_arithmetic_op
    result = _masked_arith_op(left, right, op)
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\ops\array_ops.py", line 129, in _masked_arith_op
    result[mask] = op(xrav[mask], y)
TypeError: unsupported operand type(s) for /: 'str' and 'int'
kor_fs_merge['시가총액'] = int(kor_fs_merge['시가총액']) / 100000000
D:\Anaconda3\envs\py39_32\python.exe "C:/Program Files/JetBrains/PyCharm Community Edition 2022.3.2/plugins/python-ce/helpers/pydev/pydevd.py" --multiprocess --qt-support=auto --client 127.0.0.1 --port 62630 --file D:\Fire2025\23W23\quant_py\ch10\10.6.py 
Connected to pydev debugger (build 223.8617.48)
Traceback (most recent call last):
  File "C:\Program Files\JetBrains\PyCharm Community Edition 2022.3.2\plugins\python-ce\helpers\pydev\pydevd.py", line 1496, in _exec
    pydev_imports.execfile(file, globals, locals)  # execute the script
  File "C:\Program Files\JetBrains\PyCharm Community Edition 2022.3.2\plugins\python-ce\helpers\pydev\_pydev_imps\_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "D:\Fire2025\23W23\quant_py\ch10\10.6.py", line 41, in <module>
    kor_fs_merge['시가총액'] = int(kor_fs_merge['시가총액']) / 100000000
  File "D:\Anaconda3\envs\py39_32\lib\site-packages\pandas\core\series.py", line 206, in wrapper
    raise TypeError(f"cannot convert the series to {converter}")
TypeError: cannot convert the series to <class 'int'>
python-BaseException

Python >> Pandas 전처리 - (4) Series의 Type 변환하기

https://hyemin-kim.github.io/2020/06/19/S-Python-Pandas-Pre4/


kor_fs_merge['시가총액'] = kor_fs_merge['시가총액'].astype(float) / 100000000