본문 바로가기
파이썬으로 종목 스크리너 만들기

뇌동매매 금지 - 7. 종목 스크리닝을 위한 필터 만들기

by 유티끌 2022. 9. 12.

이번 포스트에서는 종목 스크리닝을 위한 필터를 만들어 봅니다.


들어가기에 앞서..

필터 데이터들의 기반? 출처?

종목 스크리닝의 방법에는 많은 방법들과 데이터들이 있습니다만, 이번 포스트에서는 강환국 작가의 하면 된다! 퀀트 투자 에 소개된 전략들을 필터로 구현해보았습니다. 물론 모든 항목들을 구현한 것은 아니고, 현재의 구현에서 계산할 수 있는 데이터들을 이용해 필터를 만들어보았습니다.

또한 데이터를 계산하는 로직이 틀렸을 수도 있습니다

자동으로 추천을 해주진 않고, 목록에서 고르는 건 결국 본인의 몫

필터링을 한다고해서, 제일 조건이 좋은 종목을 사라! 고 권장하는 그런 것이 아닙니다. 이 시리즈에서 작성하고 있는 정도로는 아직 계산할 수 없는 팩터데이터들도 많고, 시장상황이라던지 업종 상황까지는 반영하고 있지 않습니다. 각각의 조건들에서 본인의 판단 하에 종목을 1차적으로 고른 뒤, 다시 인터넷이나 증권가의 리포트 등을 확인해가면서 최종적인 선택을 해야합니다.


필터 만들기

기본 제외 대상 종목 필터

def drop_column(df: pd.DataFrame):
    # 스팩 주식 드랍
    df.drop(
        df[df["종목명"].str.contains("스팩")].index,
        inplace=True
    )
    # 우선주 드랍
    df.drop(
        df[df["종목명"].str.endswith(("우", "우B", "우C"))].index,
        inplace=True
    )

    # 지주사 드랍
    df.drop(
        df[df["종목명"].str.endswith(("홀딩스", "지주", "지주회사"))].index,
        inplace=True
    )

    # 직전 거래일의 거래량이 0인 경우는 어떠한 이유에서 거래정지가 되어있을 확률이 높음
    df = df[df["거래량"] > 0]


    return df

일단 종목을 거를때는 아래와 같은 조건의 회사들은 거르고 시작합니다.

  • 우회 상장용 스팩 주식
  • 우선주
  • 지주사 (지주사의 재무회계는 일반회사와는 조금 다르다고 하는 강환국 작가의 글을 참고
  • 조회시점 당시 혹은 조회시점 직전 영업일의 거래량이 0인 종목

시가총액 특정 퍼센트 미만의 종목들로 추리기

우리는 저번에 재무정보 추출작업을 진행할 때, OpenDart의 무료 API호출횟수 대비 상장사 갯수가 너무 많은 탓에, DB를 구축하지 않고 필요할 떄 마다 조회하기 위해서 소형주 위주로 한 번 필터링 한다음에 재무정보를 조회하도록 필터를 작성해보았습니다.

이것은 단순히 재무정보를 조회하기 위해서 소형주를 뽑아낸 것도 있지만, 제가 참고한 서적에서 주요 전략들은 대부분 소형주를 추천하고 있기 떄문이었습니다.

filter_by_condition.py

import pandas as pd



def filtering_data_that_market_cap_under_thirty_percent(data: pd.DataFrame):
    """
    코스피, 코스닥의 종목에서 시가총액 30%이하의 종목으로 필터함.
    이 때, 기업소재지가 외국, 스팩주, 우선주, 최신거래일에 거래량이 0인 종목은 제거함.
    :param data:
    :return: DataFrame
    """

    data = drop_column(data)
    return data[data["시가총액"] <= data["시가총액"].quantile(q=0.3)].sort_values(by=["시가총액"], ascending=True)

quantile 은 전체값을 1로 보고 백분위수를 구하는 기능을 제공합니다. 0에 가까우면 가까울 수록 하위 정보입니다.
시가총액을 기준으로 0.3의 값을 주는 것은, 시가총액 하위 30% 이하의 종목들을 추출한다는 의미입니다.


위 메소드를 제외한 다른 필터에는 모두 하나에 엑셀에 여러 시트로 나누어서 담을 것이므로, 시트이름과 추출한 데이터가 담긴 dataFrame객체를 파라미터로 받고, 그 외의 조건값들을 필요에 따라 받는 식으로 작성합니다


저 PER 필터

PER이 특정값 이하인 종목들만 골라냅니다. 저는 일단 10 이하의 기업들만 추려내려고 합니다.

def filtering_low_per(sheet_name, df_copied: pd.DataFrame):
    """
    전체 데이터중 조회시점을 기준으로 PER가 10 이하인 기업.
    :param data:
    :return:
    """

    df = drop_column(df_copied)

    return (sheet_name,
            df[df["PER"] <= 10.0].sort_values(by=["PER"], ascending=True))

이 필터는 추후 엑셀파일을 작성할 때, 전체데이터로 한 번, 소형주 데이터로 한 번 씩 사용할 것입니다.

        filter_data.filtering_low_per("ALL_DATA_저PER", kospi_kosdaq_data.copy()),
        filter_data.filtering_low_per("소형주_저PER", extracted_data.copy()),

저 PBR, 저 PER

PBR과 PER이 낮은 주식을 필터링합니다.

def filtering_low_pbr_and_per(sheet_name, pbr: float, per: float, df: pd.DataFrame, all_data=False):
    """
    0.2 <= PBR < pbr
    0 < PER <= per
    :param pbr:
    :param per:
    :param df:
    :param all_data:
    :return:
    """

    df1 = df[df['PBR'].between(0.2, pbr)].copy()
    df2 = df1[df1['PER'].between(0.5, per)].copy()

    if all_data:
        df2["PBR rank"] = df2["PBR"].rank(ascending=True)
        df2["PER rank"] = df2["PER"].rank(ascending=True)
    else:
        df2["PBR rank"] = df2.groupby("연도")["PBR"].rank(ascending=True)
        df2["PER rank"] = df2.groupby("연도")["PER"].rank(ascending=True)

    df2["Total_rank"] = df2["PBR rank"] + df2["PER rank"]

    return (sheet_name,
            df2.sort_values(by=['Total_rank', '종목코드'], ascending=[True, True]).reset_index(
                drop=True)
            )

이 함수는 all_data 라는 기본 파라미터를 설정했습니다. 조회하는 모든 데이터에 대해 계산하거나, 혹은 소형주 데이터를 이용해서 필터하기 위함입니다만, 소형주 데이터는 컬럼 구성이 조금 달라서, 분기를 위해 사용하였습니다.

이 필터 역시 코스피/코스닥 전체종목으로 한 번, 소형주 데이터 대상으로 한 번, 총 2번을 필터할 것 입니다.

여기서의 필터방법은, 조회시점 기준 PBR과 PER에서, between 을 이용하여 사이값의 데이터를 구합니다.
저는 아래와 같이 데이터를 넣었습니다.

  • PBR = 0.2이상 최대값 사이
  • PER = 0.5이상 최대값 사이

이 후 각각의 PBR, PER에 대해 오름차순으로 (제일 낮은값이 1등) 랭크를 설정한 다음,
총합 랭크를 구합니다. 이후 랭크의 오름차순으로 정렬하여 내보냅니다.

중간에 copy 를 사용해가며 새로운 변수로 값을 옮기고 있는데, 이것은 데이터 프레임에서 데이터 길이가 달라진다는 warning을 회피하기 위함이었습니다. 정확한 원인은 아직도 파악을 못하고 있네요... (전체 데이터 갯수는 줄어들겠지만, 데이터 컬럼이 줄어드는게 아님)

배당률이 높은 기업 필터

배당률이 0인 기업을 제외한, 배당률이 높은 순으로 정렬하는 필터입니다.

ef filtering_high_div(sheet_name, df: pd.DataFrame):
    """
    전체 데이터중 조회 시점으로 배당률이 0제외한 기업들에서 내림차순으로 정렬한 데이터.
    :param df:
    :return:
    """

    df.drop(
        df[df["DIV"] <= 0].index,
        inplace=True
    )

    return (sheet_name,
            df.sort_values(by=["DIV"], ascending=False).reset_index(drop=True)
            )

배당성향이 높은 기업 필터

배당성향이 30~75% 사이의 기업을 찾습니다.

배당성향이란, 기업의 순이익에서 얼마만큼 배당으로 주주들에게 주는지를 확인하는 지표입니다.

def filtering_high_propensity_to_dividend(sheet_name, df: pd.DataFrame):
    """
    배당성향 30~75% 사이
    배당수익률이 가장 높은 주식
    :param data:
    :return:
    """

    dps_condition_1 = (df["DPS"] > 0)
    net_income_condition_1 = (df["당기순이익"] > 0)
    df1 = df[dps_condition_1 & net_income_condition_1].copy()

    df1["배당성향"] = ((df1["DPS"] * df1["상장주식수"]) / df1["당기순이익"]) * 100

    df2 = df1[(df1["배당성향"].between(30.0, 75.0))].copy()


    return (sheet_name,
            df2.sort_values(by=["DIV"], ascending=False).reset_index(drop=True)
            )

배당성향의 계산식은 전체 배당금을 당기 순이익으로 나누어 보았습니다.
지금 생각해보니 당기 순이익 4개 분기 합으로 나누어야하는거 아닌가 싶은 생각도 듭니다..
네이버 주식에서는 조회하고자 하는 종목 상세페이지 > 종목 분석 > 기업현황 페이지 하단의 Financial Summary 에서도 확인할 수 있습니다.

저 PBR, 고 GP/A

순자산대비 주가가 낮으면서, 자산대비 매출총이익이 높은 기업을 찾아냅니다.
여기서부터는 소형주로만 뽑은 데이터 위주로 계산되어야 합니다. 왜냐하면 GP/A 라는 정보는 재무정보 데이터를 기반으로 생성되었고, 해당 재무정보 데이터를 뽑기위한 데이터가 바로 소형주 30% 데이터였기 때문입니다.

def filtering_low_pbr_and_high_gpa(sheet_name, pbr: float, df: pd.DataFrame):
    """
    Profitable value. 저 PBR 고 GPA
    최근분기 데이터로 계산
    :param pbr:
    :param df:
    :return:
    """
    gpa_condition = (df['GP/A'] > 0)

    df.drop(
        df[df["PER"] <= 0].index,
        inplace=True
    )

    df = df[df['PBR'].between(0.2, pbr)].copy()
    df2 = df[gpa_condition]
    df2["PBR rank"] = df2.groupby("연도")["PBR"].rank(ascending=True)
    df2["GP/A rank"] = df2.groupby("연도")["GP/A"].rank(ascending=False)

    df2["PBR and GP/A score"] = df2["PBR rank"] + df2["GP/A rank"]

    return (sheet_name,
            df2.sort_values(by=['연도', 'PBR and GP/A score'], ascending=[False, True]).reset_index(
                drop=True)
            )

연도별 PBR, GP/A 랭크를 구한 다음 더해줍니다.
정렬 조건에 연도와 각 랭크를 더한 값으로 정렬해줍니다. 제일 낮은 값이 좋은 값입니다.

고NCAV/MC, 고 GP/A

시가총액 대비 청산가치가 높으면서, 순자산대비 매출총이익이 높은 회사를 필터링합니다.

def filtering_high_ncav_cap_and_gpa(sheet_name, df: pd.DataFrame):
    """
    청산가치/시가총액이 높고 GPA수치가 높은 기업들
    :param df:
    :return:
    """

    df.drop(
        df[df["당기순이익"] <= 0].index,
        inplace=True
    )

    df.drop(
        df[df["부채비율"] >= 200.0].index,
        inplace=True
    )

    df.drop(
        df[df["NCAV/MC"] <= 0.0].index,
        inplace=True
    )

    df["NCAV/MC rank"] = df.groupby("연도")["NCAV/MC"].rank(ascending=False)
    df["GP/A rank"] = df.groupby("연도")["GP/A"].rank(ascending=False)

    df["Total score"] = df["NCAV/MC rank"] + df["GP/A rank"]

    return (sheet_name,
            df.sort_values(by=['연도', "Total score"], ascending=[False, True]).reset_index(
                drop=True)
            )

밸류 팩터 필터

아래 데이터들의 합계 점수값이 제일 낮은 순으로 계산합니다.

  • 분기 PER
  • PBR
  • PCR
  • PSR
def filtering_value_factor(sheet_name, df: pd.DataFrame):
    """
    PBR, PER, PCR, PSR의 합게 점수값이 낮은 순.
    분기 PER값을 사용
    :param df:
    :return:
    """

    df.drop(
        df[df["PER_quarterly"] <= 0].index,
        inplace=True
    )

    df["PBR rank"] = df["PBR"].rank(ascending=True)
    df["PER rank"] = df["PER_quarterly"].rank(ascending=True)
    df["PCR rank"] = df["PCR"].rank(ascending=True)
    df["PSR rank"] = df["PSR"].rank(ascending=True)

    df["4 Total Value score"] = df["PBR rank"] + df["PER rank"] + df["PCR rank"] + df["PSR rank"]

     return (sheet_name,
            df.sort_values(by=['연도', '4 Total Value score'], ascending=[False, True]).reset_index(
                drop=True)
            )

각각의 데이터들의 값이 낮으면 순위가 높도록 rank 를 설정하고, 이 랭크들의 각각의 합을 구합니다. 이렇게되면 제일 지표가 좋은 값은 4 Total Value score 컬럼의 값이 4가 될 것 입니다.

업그레이드 밸류 팩터 필터

PBR을 제외한 나머지 팩터들은 분기 데이터를 사용하여 계산합니다.

def filtering_value_factor_upgrade(sheet_name, df: pd.DataFrame):
    """
    강환국 소형주 오리지널 슈퍼 가치 전략 업그레이드버전
    - 분기 PER
    - PBR
    - 분기 PFCR
    - 분기 PSR을 계산
    :param df:
    :return:
    """

    df.drop(
        df[df["PER_quarterly"] <= 0].index,
        inplace=True
    )

    df["PBR rank"] = df["PBR"].rank(ascending=True)
    df["PER rank"] = df["PER_quarterly"].rank(ascending=True)
    df["PFCR rank"] = df["PFCR"].rank(ascending=True)
    df["PSR rank"] = df["PSR"].rank(ascending=True)

    df["4 Total Value score"] = df["PBR rank"] + df["PER rank"] + df["PFCR rank"] + df["PSR rank"]

    return (sheet_name,
            df.sort_values(by=['연도', '4 Total Value score'], ascending=[False, True]).reset_index(
                drop=True)
            )

저 PBR, F Score 필터

소형주들중에 PBR이 하위 20%인 기업들 중, 아래 기준을 만족하는 기업에 점수를 매겨 확인하는 필터입니다.

단, 최근 1년간 신규주식 발행을 여부는 전체발행 주식수를 구하면 되지만, 최근 1년간 전체발행 주식수가 동일한지 계산하는 로직이 조금은 까다로울 것 같아 해당조건은 구현하진 않았습니다.

최종 선택은 관련 업종이나 종목 레포트 등도 조사해가면서 구매해야할 것 이기 때문에, 발행주식수는 네이버증권들에서 확인하면 충분할 것이라 생각합니다.

def filtering_new_F_score_and_low_pbr(sheet_name, df: pd.DataFrame):
    """
    PBR하위 25퍼센트 중
    최근 1년간 신규주식 발행을 안했으며 -> 네이버증권 등에서 확인해야함.
    최신분기 순이익이 0이상인 기업
    최신분기 영업활동현금흐름이 0 이상인 기업
    :param df:
    :return:
    """

    df.drop(
        df[df["PBR"] <= df["PBR"].quantile(q=0.25)].index,
        inplace=True
    )

    df["당기순이익 점수"] = 0
    df["영업활동현금흐름 점수"] = 0

    df.loc[df["당기순이익"] > 0, "당기순이익 점수"] = 1
    df.loc[df["영업활동현금흐름"] > 0, "영업활동현금흐름 점수"] = 1

    df["F Score"] = df["당기순이익 점수"] + df["영업활동현금흐름 점수"]

    return (sheet_name,
            df.sort_values(by=['연도', 'F Score', 'PBR'], ascending=[False, False, True]).reset_index(
                drop=True)
            )

저 PFCR

소형주 중에 PFCR이 낮은 기업들을 필터링합니다.

def filtering_low_pfcr(sheet_name, df: pd.DataFrame):
    """
    PFCR. 시가총액 / 잉여현금흐름.
    작으면 주식이 저평가 되었다고 말할 수 있음.
    :param df:
    :return:
    """

    df = df[df["PFCR"] > 0]

    return (sheet_name,
            df.sort_values(by=['연도', 'PFCR'], ascending=[False, True]).reset_index(
                drop=True)
            )

이익모멘텀 필터

영업이익과 순이익 성장률에 점수를 매겨 계산합니다.

def filtering_profit_momentum(sheet_name, df: pd.DataFrame):
    """
    전분기 대비 당기영업이익 성장률 랭크
    전분기 대비 당기순이익 성장률 랭크
    :param df:
    :return:
    """

    df["당기영업이익 성장률 순위"] = df.groupby("연도")["영업이익 증가율"].rank(method='min', ascending=False)
    df["당기순이익 성장률 순위"] = df.groupby("연도")["당기순이익 증가율"].rank(method='min', ascending=False)

    df["모멘텀 순위"] = df["당기영업이익 성장률 순위"] + df["당기순이익 성장률 순위"]

    return (sheet_name,
            df.sort_values(by=['연도', '모멘텀 순위'], ascending=[False, True]).reset_index(
                drop=True)
            )

여기서는 각 연도별로 기업들간의 성장률 순위를 계산하고 있으나, 우리가 확인해야할 것은 해당 기업이 가장 최근에 공시한 데이터로 비교를 해보면 될 것 같습니다. (최신연도 이외의 나머지 데이터들은 참고목적의 데이터)

밸류 이익모멘텀 필터

위의 이익모멘텀에 밸류 데이터들을 섞은 것입니다.

def filtering_value_and_profit_momentum(sheet_name, df: pd.DataFrame):
    """
    전분기 대비 당기영업이익 성장률 랭크
    전분기 대비 당기순이익 성장률 랭크
    분기 PER,
    PBR,
    PFCR,
    PSR
    :param df:
    :return:
    """

    df.drop(
        df[df["PER_quarterly"] <= 0].index,
        inplace=True
    )

    df["당기영업이익 성장률 순위"] = df.groupby("연도")["영업이익 증가율"].rank(method='min', ascending=False)
    df["당기순이익 성장률 순위"] = df.groupby("연도")["당기순이익 증가율"].rank(method='min', ascending=False)

    df["PBR rank"] = df["PBR"].rank(ascending=True)
    df["PER rank"] = df["PER_quarterly"].rank(ascending=True)
    df["PFCR rank"] = df["PFCR"].rank(ascending=True)
    df["PSR rank"] = df["PSR"].rank(ascending=True)

    df["모멘텀 순위"] = (df["당기영업이익 성장률 순위"] + df["당기순이익 성장률 순위"] + df["PBR rank"] + df["PER rank"] + df["PFCR rank"] + df[
        "PSR rank"]) / 6

    return (sheet_name,
            df.sort_values(by=['연도', '모멘텀 순위'], ascending=[False, True]).reset_index(
                drop=True)
            )

최종 순위는 각각의 순위들의 평균을 낸 값으로 계산합니다.


마무리

일단 책을 봐가면서 최대한 만들어볼 수 있는 조건필터들을 만들어보았지만, 좀 더 어떠한 값들을 더 추출해내고, 조합해볼 수 있을 것 같습니다.

물론 중요한 것은 이 정보들을 가지고 투자를 얼마나 잘할 수 있냐 이거겠지요.

다음 포스트에서는 이 모든 흐름을 하나로 실행하는 main.py를 다듬어봅니다.

반응형

댓글