From b1a898c1e8261e5bfe4306c61d496eacc88a6208 Mon Sep 17 00:00:00 2001 From: ayuriel Date: Fri, 7 Feb 2025 17:54:52 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20streamlit=20=EC=9E=84=EC=8B=9C=20commi?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + streamlit-quant/app.py | 13 ++ streamlit-quant/pages/crawling.py | 6 + streamlit-quant/pages/super_quality.py | 41 ++++ streamlit-quant/pages/super_value_momentum.py | 3 + streamlit-quant/src/current_stock.py | 192 ++++++++++++++++++ streamlit-quant/strategy/f-score.py | 19 ++ 7 files changed, 276 insertions(+) create mode 100644 README.md create mode 100644 streamlit-quant/app.py create mode 100644 streamlit-quant/pages/crawling.py create mode 100644 streamlit-quant/pages/super_quality.py create mode 100644 streamlit-quant/pages/super_value_momentum.py create mode 100644 streamlit-quant/src/current_stock.py create mode 100644 streamlit-quant/strategy/f-score.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..b451cc3 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# 실행 +streamlit run .\streamlit-quant\app.py --server.port 20000 diff --git a/streamlit-quant/app.py b/streamlit-quant/app.py new file mode 100644 index 0000000..616436e --- /dev/null +++ b/streamlit-quant/app.py @@ -0,0 +1,13 @@ +import streamlit as st + +crawling_page = st.Page("pages/crawling.py", title="크롤링") +super_quality_page = st.Page("pages/super_quality.py", title="슈퍼 퀄리티 전략") +super_value_momentum_page = st.Page("pages/super_value_momentum.py", title="슈퍼 밸류 모멘텀 전략") + +pg = st.navigation({ + '크롤링': [crawling_page], + '전략': [super_quality_page, super_value_momentum_page], +}) + +st.set_page_config(page_title="콴트 매니저", page_icon=":material/edit:") +pg.run() \ No newline at end of file diff --git a/streamlit-quant/pages/crawling.py b/streamlit-quant/pages/crawling.py new file mode 100644 index 0000000..a2869ba --- /dev/null +++ b/streamlit-quant/pages/crawling.py @@ -0,0 +1,6 @@ +import streamlit as st + +st.button(label='동작1') +st.button(label='동작2') +st.button(label='동작3') +st.button(label='동작4') \ No newline at end of file diff --git a/streamlit-quant/pages/super_quality.py b/streamlit-quant/pages/super_quality.py new file mode 100644 index 0000000..5ee1f0f --- /dev/null +++ b/streamlit-quant/pages/super_quality.py @@ -0,0 +1,41 @@ +import streamlit as st + +st.write(""" +'신F-스코어 3점 + 고GP/A 전략'을 '강환국 슈퍼 퀄리티 전략'이라 명명한다. +이 전략은 신F-스코어가 3점인 종목을 매수하되, GP/A로 순위를 매겨서 순위가 높은 종목만 매수하는 것이다. +이 경우 한국에서 수익이 어땠을지 분석해보자. +연도별로 신F-스코어 3점을 충족하는 종목은 600-700개였다.\n +신F-스코어 3점 기업 내에서도 GP/A가 높은 종목이 3점 종목 평균보다 CAGR 기준으로 3-4% 더 높았다. +반대로 GP/A가 낮은 종목의 수익률은 상대적으로 저조했다.\n\n +--- +투자 전략: 강환국 슈퍼 퀄리티 전략 1.0\n +레벨: 초, 중급\n +스타일: 퀄리티\n +기대 CAGR: 약 20%\n +매수 전략: +- 신F-스코어 3점 종목만 매수\n +- 여기에 GP/A 순위를 부여, 순위 높은 20-30종목을 매수\n +매도 전략: 연 1회 리밸런싱\n\n\n +--- +지금까지 소개한 거의 모든 전략에서 소형주 전략이 전체 주식 수익률보다 높았다. +시가총액 하위 20% 종목의 CAGR을 분석해보았다.\n\n +--- +투자 전략: 강환국 슈퍼 퀄리티 전략 2.0\n +레벨: 초, 중급\n +스타일: 퀄리티\n +기대 CAGR: 20% 이상\n +매수 전략: +아래 조건을 만족하는 20-30종목 매수\n +- 신F-스코어 3점 종목만 매수\n +- 여기에 GP/A 순위를 부여, 순위 높은 종목만 매수\n +- 단, 소형주(시가총액 최저 20%)만 매수\n +매도 전략: 연 1회 리밸런싱\n +--- +소형주 중 신F-스코어가 3점인 종목을 찾아보니 2004-2016년 구간에 80-100개 종목이 남았다. +그 주식들을 통째로 매수해도 CAGR 34.55%를 벌수 있었다! +정말 상당한 수익이다. +이 종목들을 다 샀으면 총 1,159개 종목 중 14개가 파산했다.(1.2%) +또 1년간 마이너스 수익을 기록한 종목이 29.7%였다.\n +신F-스코어가 3점인 종목 중 GP/A가 높은 종목 위주로 매수했으면 (1) CAGR도 조금 개선되고 (2) 최상 30개 종목을 매수했을 경우 선택받은 종목 360개 중 파산한 기업은 단 1개였다. +F-스코어와 GP/A는 엄청난 잠재력을 지닌 콤비네이션임이 분명하다. +""") \ No newline at end of file diff --git a/streamlit-quant/pages/super_value_momentum.py b/streamlit-quant/pages/super_value_momentum.py new file mode 100644 index 0000000..79d5a69 --- /dev/null +++ b/streamlit-quant/pages/super_value_momentum.py @@ -0,0 +1,3 @@ +import streamlit as st + +st.write("슈퍼 밸류 모멘텀 전략 2.0") \ No newline at end of file diff --git a/streamlit-quant/src/current_stock.py b/streamlit-quant/src/current_stock.py new file mode 100644 index 0000000..80d9529 --- /dev/null +++ b/streamlit-quant/src/current_stock.py @@ -0,0 +1,192 @@ +import re +import time +from io import BytesIO + +import numpy as np +import pandas as pd +import requests as rq +from tqdm import tqdm +from bs4 import BeautifulSoup +from dotenv import load_dotenv +import quantcommon + +load_dotenv() + +GEN_OTP_URL = 'http://data.krx.co.kr/comm/fileDn/GenerateOTP/generate.cmd' +DOWN_URL = 'http://data.krx.co.kr/comm/fileDn/download_csv/download.cmd' + +# 최근 영업일을 가져옴 +def get_latest_biz_day(): + url = 'https://finance.naver.com/sise/sise_deposit.nhn' + data = rq.post(url) + data_html = BeautifulSoup(data.content, 'lxml') + parse_day = data_html.select_one('div.subtop_sise_graph2 > ul.subtop_chart_note > li > span.tah').text + biz_day = re.findall('[0-9]+', parse_day) + biz_day = ''.join(biz_day) + return biz_day + + +# 업종 분류 현황 가져옴 +def get_stock_data(biz_day, mkt_id): + # logging.basicConfig(level=logging.DEBUG) + gen_otp_data = { + 'locale': 'ko_KR', + 'mktId': mkt_id, # STK: 코스피, KSQ: 코스닥 + '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/index.cmd?menuId=MDC0201050201', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + } + + otp = rq.post(url=GEN_OTP_URL, data=gen_otp_data, headers=headers, verify=False) + # # 요청 디버깅 + # print("===== Request Details =====") + # print(f"Method: {otp.request.method}") + # print(f"URL: {otp.request.url}") + # print(f"Headers: {otp.request.headers}") + # print(f"Body: {otp.request.body}") + # + # # 응답 디버깅 + # print("===== Response Details =====") + # print(f"Status Code: {otp.status_code}") + # print(f"Headers: {otp.headers}") + # print(f"Body: {otp.text}") + + down_sector = rq.post(url=DOWN_URL, data={'code': otp.text}, headers=headers) + return pd.read_csv(BytesIO(down_sector.content), encoding='EUC-KR') + + +# 개별 지표 조회 +def get_ind_stock_data(biz_day): + gen_otp_data = { + 'locale': 'ko_KR', + '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/index.cmd?menuId=MDC0201050201', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + } + + otp = rq.post(url=GEN_OTP_URL, data=gen_otp_data, headers=headers, verify=False) + + down_ind_sector = rq.post(url=DOWN_URL, data={'code': otp.text}, headers=headers) + return pd.read_csv(BytesIO(down_ind_sector.content), encoding='EUC-KR') + + +def process_for_total_stock(biz_day): + # 업종 분류 현황(코스피, 코스닥) + sector_stk = get_stock_data(biz_day, 'STK') + sector_ksq = get_stock_data(biz_day, 'KSQ') + # 각각 조회 후 합침 + krx_sector = pd.concat([sector_stk, sector_ksq]).reset_index(drop=True) + krx_sector['종목명'] = krx_sector['종목명'].str.strip() + krx_sector['기준일'] = biz_day + + # 개별 지표 조회 + krx_ind = get_ind_stock_data(biz_day) + krx_ind['종목명'] = krx_ind['종목명'].str.strip() + krx_ind['기준일'] = biz_day + + # 데이터 정리 + # 종목, 개별 중 한군데만 있는 데이터 삭제(선박펀드, 광물펀드, 해외종목 등) + diff = list(set(krx_sector['종목명']).symmetric_difference(set(krx_ind['종목명']))) + + kor_ticker = pd.merge(krx_sector, + krx_ind, + on=krx_sector.columns.intersection( + krx_ind.columns).tolist(), + how='outer') + # 일반적인 종목과 SPAC, 우선주, 리츠, 기타 주식을 구분 + 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['기준일']) + + save_ticker(kor_ticker) + + +def save_ticker(ticker): + con = quantcommon.QuantCommon().connect() + + mycursor = con.cursor() + query = f""" + insert into kor_ticker (종목코드,종목명,시장구분,종가,시가총액,기준일,EPS,선행EPS,BPS,주당배당금,종목구분) + values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) as new + on duplicate key update + 종목명=new.종목명,시장구분=new.시장구분,종가=new.종가,시가총액=new.시가총액,EPS=new.EPS,선행EPS=new.선행EPS, + BPS=new.BPS,주당배당금=new.주당배당금,종목구분 = new.종목구분; + """ + + args = ticker.values.tolist() + + mycursor.executemany(query, args) + con.commit() + + con.close() + + +# WICS 기준 섹터정보 크롤링 +def process_for_wics(biz_day): + 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['기준일']) + save_sector(kor_sector) + + +def save_sector(sector): + con = quantcommon.QuantCommon().connect() + + mycursor = con.cursor() + query = f""" + insert into kor_sector (IDX_CD, CMP_CD, CMP_KOR, SEC_NM_KOR, 기준일) + values (%s,%s,%s,%s,%s) as new + on duplicate key update + IDX_CD = new.IDX_CD, CMP_KOR = new.CMP_KOR, SEC_NM_KOR = new.SEC_NM_KOR + """ + + args = sector.values.tolist() + + mycursor.executemany(query, args) + con.commit() + + con.close() + + +if __name__ == '__main__': + latest_biz_day = get_latest_biz_day() + process_for_total_stock(latest_biz_day) + process_for_wics(latest_biz_day) diff --git a/streamlit-quant/strategy/f-score.py b/streamlit-quant/strategy/f-score.py new file mode 100644 index 0000000..64f5393 --- /dev/null +++ b/streamlit-quant/strategy/f-score.py @@ -0,0 +1,19 @@ +import pandas as pd +import numpy as np +import quantcommon + +# DB 연결 +engine = quantcommon.QuantCommon().create_engine() + +ticker_list = pd.read_sql(""" +select * from kor_ticker +where 기준일 = (select max(기준일) from kor_ticker) + and 종목구분 = '보통주'; +""", con=engine) + +value_list = pd.read_sql(""" +select * from kor_value +where 기준일 = (select max(기준일) from kor_value); +""", con=engine) + +engine.dispose()