7  포트폴리오 구성하기

퀀트 투자는 크게 포트폴리오 운용 전략과 트레이딩 전략으로 나눌 수 있습니다. 포트폴리오 운용 전략은 과거 주식 시장을 분석해 좋은 주식의 기준을 찾아낸 후 해당 기준에 만족하는 종목을 매수하거나, 이와 반대에 있는 나쁜 주식을 공매도하기도 합니다. 투자의 속도가 느리며, 다수의 종목을 하나의 포트폴리오로 구성해 운용하는 특징이 있습니다. 반면 트레이딩 전략은 주식이 오르거나 내리는 움직임을 연구한 후 각종 지표를 이용해 매수 혹은 매도하는 전략입니다. 투자의 속도가 빠르며 소수의 종목을 대상으로 합니다. 본 수업에서는 퀀트 전략을 이용한 종목선정에 대해 알아보겠습니다.

7.1 팩터 이해하기

하나 혹은 소수의 주식만을 연구해서 주식이 오르거나 내리는 공통적인 이유를 찾는 것은 불가능에 가깝지만, 그룹으로 살펴보면 어느 정도 파악이 가능합니다. 어떠한 특성, 예를 들어 기업의 크기 별로 주식들을 묶은 후 수익률을 살펴보면, 크기가 큰 기업의 수익률이 좋았는지 아니면 작은 기업의 수익률이 좋았는지 알 수 있습니다. 즉, 오르는 주식과 내리는 주식은 애초에 가지고 있는 특성이 다르며 그로 인해 수익률에도 차이가 있습니다. 이처럼 주식의 수익률에 영향을 미치는 특성들을 ’팩터(Factor)’라고 하며, 주식의 수익률은 이러한 팩터들로 대부분 설명됩니다. 주식이 가지고 있는 특성만 제대로 알아도 오를만한 주식을 선별하거나, 혹은 내릴만한 주식을 걸러낼 수 있습니다.

그러나 단순히 특성을 기준으로 수익률이 높거나 낮다고 해서 팩터로 인정되는 것은 아닙니다. 팩터로 인정되고 전략으로 사용되기 위해서는 아래의 조건을 충족해야 합니다.

  • 지속성: 오랜 기간, 그리고 여러 경제 상황에서도 꾸준히 작동해야 합니다. 몇 달 혹은 몇 년 동안의 기간에서만 작동한다면 우연의 결과일 가능성이 매우 큽니다.

  • 범용성: 특정 국가에서만 작동하는 것이 아닌 다양한 국가, 지역, 섹터, 자산군에서도 작동해야 합니다. 전세계 중 한국에서만 작동하는 전략이라면 이 역시 우연일 가능성이 큽니다.

  • 이해 가능성: 전략이 작동하는 이유 및 지속 가능한지에 대한 설명이 가능해야 합니다. 수익률이 높은 이유를 경제학이나 이론적으로 설명할 수 있어야 앞으로도 수익률이 높을 것이라 믿을 수 있습니다. 이유가 없는 효과는 우연 혹은 과최적화의 결과일 가능성이 매우 높습니다.

  • 강건성: 같은 팩터라면 비슷한 정의(예: 가치주를 정의하는 PBR, PER, PSR 등) 모두에서 작동해야 합니다. 전략이 작동하는 이유가 명확하다면 정의가 약간씩 달라도 당연히 작동해야 하며, 결과 역시 비슷해야 합니다.

  • 투자 가능성: 이론적으로만 작동하는 것이 아닌 실제로 투자가 가능해야 합니다. 아무리 좋은 전략도 수수료, 세금, 법률적인 문제 등으로 실제 투자가 불가능하다면 돈을 벌 수 없기 때문입니다.

퀀트 운용 전략에서는 팩터의 강도가 양인 종목들로 구성한 포트폴리오는 향후 수익률이 높을 것으로 예상되어 매수를 하며, 팩터의 강도가 음인 종목들로 구성한 포트폴리오는 반대로 향후 수익률이 낮을 것으로 예상되어 매수를 하지 않거나 공매도를 합니다. 기본적인 팩터들에 대해 알아보고, 우리가 구한 데이터를 바탕으로 각 팩터별 투자 종목을 선택하는 방법을 알아보겠습니다.

7.2 데이터 불러오기

먼저 샘플로 사용할 주가 및 재무제표 데이터를 다운로드 받습니다. 데이터는 아래 링크에 .sql 파일로 업로드 되어 있습니다.

https://drive.google.com/file/d/13KLFlZTGJvyrlXQYQ_mR0RgAbfYtufti/view?usp=share_link

[다운로드] 버튼을 눌러 파일을 다운로드 합니다. 그 후 SQL에서 해당 데이터베이스를 불러옵니다.

  1. Navigator에서 Administration 부분을 클릭한 후 Data Import를 선택합니다.
  2. Import from Self-Contained File를 선택한 후 […]을 눌러 다운로드 받은 파일을 선택합니다.
  3. Default Target Schema 우측의 New를 누른 후 저장될 데이터베이스 이름을 입력합니다.
  4. 하단의 Start Import를 클릭합니다.

데이터베이스를 확인해보면 티커, 주가, 재무제표 데이터가 들어와 있습니다.

7.3 밸류 전략

가치주 효과란 내재 가치 대비 낮은 가격의 주식(저PER, 저PBR 등)이, 내재 가치 대비 비싼 주식(고PER, 고PBR)보다 수익률이 높은 현상을 뜻합니다. 가치주 효과가 발생하는 원인은 바로 사람들이 가치주(저밸류에이션)를 기피하고, 성장주(고밸류에이션)를 선호하기 때문입니다. 달리 말하면 사람들이 기피한 주식이 가치주가 되었다고 할 수도 있습니다. 가치주는 일반적으로 차입비율이 높고, 수익의 변동성이 크며, 경기가 좋지 않을 때 더 위험한 경향이 있습니다. 사람들은 이처럼 위험한 주식에 필요 이상으로 과민 반응을 보입니다. 그로 인해 주가가 하락하고 가치주가 되는 것입니다. 반면 인간은 익숙한 것을 안전하다고 착각하는 경향이 있습니다. 최근 성과가 좋은 주식은 여러 매체를 통해 접하기 쉬운데, 이런 주식을 안전하다고 착각해 많은 사람이 매수에 나섭니다. 그로 인해 주가가 상승하고 고평가주가 됩니다. 보고 싶은 것만 보는 확증 편향으로 인해 투자자들은 위험하다고 생각되는 가치주가 망할 것 같은 이유만 찾아 더욱 기피하고, 안전하다고 생각되는 성장주는 영원히 상승할 것 같은 이유만 찾아 더욱 선호합니다. 그러나 가치주가 생각보다 위험하지 않다는 것을, 성장주가 너무 많이 상승해 안전하지 않다는 것을 깨닫는 순간 주가는 원래 수준으로 회귀하기 마련이고, 이로 인해 가치주 효과가 발생합니다.

7.3.1 French Library 데이터 불러오기

파마-프렌치 모형으로 유명한 프렌치 교수가 제공하는 라이브러리에서는 다양한 팩터 데이터를 다운로드 받을 수 있습니다.

https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html

해당 데이터를 분석하기 위해 사이트에 접속하여 데이터를 내려받아 압축을 푼 후 csv 파일을 불러오는 방법 보다는, R 내에서 데이터를 다운로드 받은 후 불러오는 것이 훨씬 효율적입니다. 또한 이미 개발된 패키지를 사용할 경우 이러한 작업을 매우 쉽게 할수도 있습니다.

https://nareal.github.io/frenchdata/articles/basic_usage.html

R에서 해당 패키지를 사용해 팩터 데이터를 다운로드 받은 후 성과를 확인해보도록 하겠습니다.

library(frenchdata)
data_sets = get_french_data_list()

data_sets$files_list
# A tibble: 297 × 3
   name                                                file_url          detai…¹
   <chr>                                               <chr>             <chr>  
 1 Fama/French 3 Factors                               ftp/F-F_Research… Data_L…
 2 Fama/French 3 Factors [Weekly]                      ftp/F-F_Research… Data_L…
 3 Fama/French 3 Factors [Daily]                       ftp/F-F_Research… Data_L…
 4 Fama/French 5 Factors (2x3)                         ftp/F-F_Research… Data_L…
 5 Fama/French 5 Factors (2x3) [Daily]                 ftp/F-F_Research… Data_L…
 6 Portfolios Formed on Size                           ftp/Portfolios_F… Data_L…
 7 Portfolios Formed on Size [ex.Dividends]            ftp/Portfolios_F… Data_L…
 8 Portfolios Formed on Size [Daily]                   ftp/Portfolios_F… Data_L…
 9 Portfolios Formed on Book-to-Market                 ftp/Portfolios_F… Data_L…
10 Portfolios Formed on Book-to-Market [ex. Dividends] ftp/Portfolios_F… Data_L…
# … with 287 more rows, and abbreviated variable name ¹​details_url

먼저 필요한 패키지들을 불러온 후, get_french_data_list() 함수를 사용해 다운로드 받을 수 있는 데이터를 조회합니다. data_sets의 files_list에는 다운로드 받을 수 있는데 데이터와 해당 url이 표시되어 있습니다. 이 중 우리는 name 컬럼의 데이터 이름을 알면 됩니다. 이 중 밸류에 해당하는 데이터의 이름은 [Portfolios Formed on Book-to-Market] 입니다. B/M에서 B는 장부가치(Book Value), M는 시장가치(Market Value)로써, 이는 PBR의 역수라고 생각해도 됩니다. 즉 해당값이 높을수록 저PBR 주식을 의미합니다. 해당 데이터를 다운로드 받도록 하겠습니다.

ff_value = download_french_data('Portfolios Formed on Book-to-Market')
New names:
New names:
New names:
New names:
New names:
New names:
New names:
New names:
• `` -> `...1`
ff_value$subsets
# A tibble: 8 × 2
  name                                                    data                
  <chr>                                                   <list>              
1 Value Weight Returns -- Monthly                         <spc_tbl_>          
2 Equal Weight Returns -- Monthly                         <spc_tbl_>          
3 Value Weight Returns -- Annual from January to December <spc_tbl_ [95 × 20]>
4 Equal Weight Returns -- Annual from January to December <spc_tbl_ [95 × 20]>
5 Number of Firms in Portfolios                           <spc_tbl_>          
6 Average Firm Size                                       <spc_tbl_>          
7 Sum of BE / Sum of ME                                   <spc_tbl_ [97 × 20]>
8 Value Weight Average of BE / ME                         <spc_tbl_ [97 × 20]>

리스트 중 subsets를 확인해보면 월간수익률(시가총액가중평균, 동일가중평균), 연간수익률(시가총액가중평균, 동일가중평균) 및 기타 데이터가 포함되어 있습니다. 이 중 일반적으로 많이 사용하는 시가총액가중포트폴리오의 월간 수익률 (Value Weighted Returns – Monthly)를 확인해보겠습니다.

ff_value_vw = ff_value$subsets$data[[1]]
head(ff_value_vw)
# A tibble: 6 × 20
    date `<= 0` `Lo 30` `Med 40` `Hi 30` `Lo 20` `Qnt 2` `Qnt 3` `Qnt 4` `Hi 20`
   <dbl>  <dbl>   <dbl>    <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <dbl>
1 192607  12.1     5.55     1.86    1.54    3.18    5.41    1.78    2.41    0.6 
2 192608  -9.73    2.65     2.67    5.61    1       4.01    2.05    4.59    7.1 
3 192609 -15.2     1.28     0.07   -0.71   -1.04    3.04   -0.29   -0.19   -1.46
4 192610  -5.63   -3.6     -2.41   -3.55   -2.89   -2.96   -2.2    -4.2    -4.28
5 192611   5.58    3.13     2.95    2.94    4.12    2.56    1.9     3.96    2.48
6 192612  -6.13    2.96     2.59    2.52    1.68    3.33    1.82    5.2     2.06
# … with 10 more variables: `Lo 10` <dbl>, `Dec 2` <dbl>, `Dec 3` <dbl>,
#   `Dec 4` <dbl>, `Dec 5` <dbl>, `Dec 6` <dbl>, `Dec 7` <dbl>, `Dec 8` <dbl>,
#   `Dec 9` <dbl>, `Hi 10` <dbl>
  • <=0: PBR이 0 이하인 기업들의 포트폴리오
  • Lo 30, Med 40, Hi 30: PBR 기준 상위 30%, 30-70%, 하위 30%로 나눈 포트폴리오
  • Lo 20, Qnt 2, Qnt 3, Qnt 4, Hi 20: PBR 기준 상위 20%, 20-40%, 40-60%, 60-80%, 80-100%로 나눈 포트폴리오
  • Lo 10, Dec 2, Dec 3, …, Dec 9, Hi 19: PBR 기준 상위 10% 씩으로 나눈 포트폴리오

이 중 20%씩 나눈 [Lo 20, Qnt 2, Qnt 3, Qnt 4, Hi 20] 열만 선택하여 누적 수익률을 확인보도록 하겠습니다.

library(dplyr)

Attaching package: 'dplyr'
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
library(tidyr)
library(ggplot2)
library(lubridate)
Loading required package: timechange

Attaching package: 'lubridate'
The following objects are masked from 'package:base':

    date, intersect, setdiff, union
library(zoo)

Attaching package: 'zoo'
The following objects are masked from 'package:base':

    as.Date, as.Date.numeric
data_to_plot = function(data) {
  
  data %>%
    mutate(date = as.character(date)) %>%
    mutate(date = as.Date(as.yearmon(date, "%Y%m"), frac = 1)) %>%
    mutate(across(!date, ~.x / 100)) %>%
    mutate(across(!date, ~log(1+.x))) %>%
    mutate(across(!date, ~cumsum(.x))) %>%
    pivot_longer(-date) %>%
    mutate(name = factor(name, levels = .$name %>% unique)) %>%
    ggplot(aes(x = date, y = value, color = name)) +
    geom_line() +
    xlab('') +
    ylab('') +
    theme_bw() +
    theme(legend.title=element_blank())
  
}

먼저 데이터를 클렌징한 후 그림으로 나타내는 함수를 만듭니다.

  • date열을 yyyy-mm-dd로 변경
  • 해당 데이터에서는 1이 1%를 의미하므로, 올바른 계산을 위해 100으로 나누어 줌
  • 로그수익률로 치환
  • cumsum() 함수를 통해 누적합 계산
  • pivot_longer() 함수를 통해 형태 변경
  • name 열의 순서 지정을 위해 팩터 levels 재설정
  • 그림으로 나타내기

이제 PBR 기준 5분위 열만 선택한 후 해당 함수를 적용합니다.

ff_value_vw %>% select(date, `Lo 20`, `Qnt 2`, `Qnt 3`, `Qnt 4`, `Hi 20`) %>%
  data_to_plot()

Hi 20, 즉 B/M이 높은(PBR이 낮은) 포트폴리오의 누적 수익률이 가장 높으며, B/M이 낮을수록(PBR이 높을수록) 누적 수익률이 낮아집니다.

ff_value_vw %>% select(date, `Lo 20`, `Qnt 2`, `Qnt 3`, `Qnt 4`, `Hi 20`) %>%
  pivot_longer(-date) %>%
  mutate(name = factor(name, levels = .$name %>% unique)) %>%
  ggplot(aes(x = name, y = value)) +
  geom_boxplot()

박스 플랏을 분석해 보면 PBR이 낮을수록 수익률의 변동성은 크지만, 큰 수익이 나는 경우가 더 많습니다.

이번에는 고PBR 대비 저PBR 수익률인 HML 팩터의 수익률을 살펴보겠습니다. 흔히 롱숏 모델을 비교할때는 상하위 30% 수익률을 이용합니다.

ff_value_vw %>% select(date, `Lo 30`, `Hi 30`) %>%
  mutate(HML = `Hi 30` - `Lo 30`) %>%
  select(date, HML) %>%
  data_to_plot()

1940년 이후 꾸준히 상향하며 저PBR이 고PBR 대비 뛰어난 성과를 기록하였습니다. 반면 2008년 이후 10여년 동안 하락하다가, 2020년을 기점으로 다시 반등하는 모습입니다.

French 라이브러리에서는 PBR외에도 PER나 PCR 팩터의 수익률도 확인할 수 있으며, 미국이 아닌 글로벌 수익률도 확인할 수 있습니다.

7.3.2 밸류 포트폴리오 구하기

가치주에 투자하는 것이 훨씬 수익률이 높다는 점을 확인하였으니, 국내 종목들 중 가치주에는 어떠한 것이 있는 확인해보도록 합니다. 먼저 전통적인 가치지표인 PER와 PBR이 낮은 종목을 선정해보도록 합니다.

library(DBI)
library(RMySQL)

con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', # 위에서 설정한 root 비밀번호
  host = '127.0.0.1',
  dbname = 'stock_db' # 사용하고자 하는 스키마
)

ticker = dbGetQuery(con,
                    "select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker)
    and 종목구분 = '보통주';")
                  
value = dbGetQuery(con ,
"select * from kor_value
where 기준일 = (select max(기준일) from kor_value);
")

dbDisconnect(con)
[1] TRUE

먼저 DB에서 티커 테이블과 가치지표 테이블을 불러옵니다. 티커는 최신일 기준 및 보통주에 해당하는 종목만 불러오며, 가치지표는 최신일 기준 데이터를 불러옵니다.

value = value %>%
  mutate(값 = ifelse(값 <=0, NA, 값)) %>%
  pivot_wider(names_from = '지표', values_from = '값') %>%
  select(-기준일)

value_bind = ticker %>% 
  left_join(value)
Joining, by = "종목코드"
value_bind %>% head()
  종목코드     종목명 시장구분  종가    시가총액     기준일   EPS 선행EPS
1   000020   동화약품    KOSPI  8650 2.41607e+11 2022-10-14   647      NA
2   000040   KR모터스    KOSPI   599 5.75869e+10 2022-10-14    NA      NA
3   000050       경방    KOSPI 10800 2.96085e+11 2022-10-14   872      NA
4   000060 메리츠화재    KOSPI 29600 3.36798e+12 2022-10-14  5768    6808
5   000070 삼양홀딩스    KOSPI 63800 5.46401e+11 2022-10-14 30711      NA
6   000080 하이트진로    KOSPI 24650 1.72879e+12 2022-10-14  1031    1984
     BPS 주당배당금 종목구분     DY    PBR    PCR     PER    PSR
1  12534        180   보통주 0.0208 0.6718 5.7117 12.5185 0.7600
2    385          0   보통주     NA 1.1705     NA      NA 0.4359
3  30033        125   보통주 0.0116 0.3900 4.7602 15.1838 0.7365
4  22086        620   보통주 0.0209 1.8143 2.7349  4.1457     NA
5 226314       3000   보통주 0.0470 0.2223 3.3317  2.8518 0.1653
6  15657        800   보통주 0.0325 1.5829 4.2258 18.0647 0.7408
  1. 일부 종목은 가치지표가 0보다 작은 경우(예: 적자기업의 경우 PER가 음수, 혹은 배당수익률이 0%인 종목)가 있으며 이러한 데이터는 NA로 변경합니다.
  2. 테이블을 가로로 긴 형태로 변경합니다.
  3. 두 테이블을 합칩니다.

이제 PER와 PBR이 낮은 종목을 찾아보도록 합니다.

value_bind %>%
  mutate(across(c(PBR, PER), min_rank, .names = "rank_{col}")) %>%
  mutate(rank = min_rank(rank_PBR + rank_PER)) %>%
  filter(rank <= 20) 
   종목코드           종목명 시장구분   종가    시가총액     기준일   EPS
1    000880             한화    KOSPI  24000 1.79901e+12 2022-10-14  9781
2    001390         KG케미칼    KOSPI  21450 2.97428e+11 2022-10-14  5749
3    001940      KISCO홀딩스    KOSPI  12150 1.96543e+11 2022-10-14  8106
4    002030           아세아    KOSPI 110500 2.42108e+11 2022-10-14 56785
5    003380         하림지주   KOSDAQ   6630 7.42597e+11 2022-10-14  4129
6    006200   한국전자홀딩스    KOSPI   1155 5.40576e+10 2022-10-14   531
7    007860             서연    KOSPI   5270 1.23739e+11 2022-10-14  1091
8    008060             대덕    KOSPI   5990 2.03002e+11 2022-10-14    NA
9    009970   영원무역홀딩스    KOSPI  51200 6.98142e+11 2022-10-14 19026
10   023590         다우기술    KOSPI  17150 7.69463e+11 2022-10-14  8693
11   032190       다우데이타   KOSDAQ  13850 5.30455e+11 2022-10-14  3838
12   037400 우리엔터프라이즈   KOSDAQ   2220 5.82158e+10 2022-10-14   899
13   052300     초록뱀컴퍼니   KOSDAQ    570 6.55956e+10 2022-10-14   942
14   088350         한화생명    KOSPI   2020 1.75443e+12 2022-10-14  1455
15   090740       연이비앤티   KOSDAQ     75 1.68446e+09 2022-10-14  1328
16   101360         이엔드디   KOSDAQ  26000 2.75825e+11 2022-10-14   992
17   106240     파인테크닉스   KOSDAQ   2190 3.48715e+10 2022-10-14   983
18   139480           이마트    KOSPI  84900 2.36666e+12 2022-10-14 56152
19   151860           KG ETS   KOSDAQ   8410 3.02760e+11 2022-10-14  1389
20   296640         이노룰스   KOSDAQ  19850 1.02064e+11 2022-10-14   813
   선행EPS    BPS 주당배당금 종목구분     DY    PBR    PCR    PER    PSR
1     8821  52527        750   보통주 0.0312 0.0943 0.3589 0.9098 0.0323
2       NA  34343        500   보통주 0.0233 0.1539 1.1798 0.5282 0.0531
3       NA  68463        400   보통주 0.0329 0.1393 4.7589 1.2965 0.1053
4       NA 491013       3000   보통주 0.0271 0.1407 1.2185 1.3473 0.1211
5       NA  27945        100   보통주 0.0151 0.1684 0.6366 1.0822 0.0572
6       NA   2900          0   보통주     NA 0.1777 1.2175 1.5314 0.1492
7       NA  21587        100   보통주 0.0190 0.1320 0.4529 1.9579 0.0451
8       NA  16976        300   보통주 0.0501 0.1455 0.6002 1.6945 0.1296
9    22866 140307       2000   보통주 0.0391 0.2158 1.7153 1.0987 0.1846
10      NA  45679        600   보통주 0.0350 0.1673     NA 1.1148 0.0962
11      NA  24053        300   보통주 0.0217 0.1085     NA 0.7750 0.0619
12      NA   4170          0   보통주     NA 0.2277 0.9153 1.0267 0.0375
13      NA   2655          0   보통주     NA 0.1442 3.9515 1.1137 0.3827
14     556  15004          0   보통주     NA 0.1488 0.4769 1.5061     NA
15      NA   2489          0   보통주     NA 0.0363 0.3584 0.0952 0.0148
16      NA   7068          0   보통주     NA 0.1615 0.1615 0.1615 0.1615
17      NA   2512         25   보통주 0.0114 0.2265 0.7647 0.6086 0.0716
18   14456 369202       2000   보통주 0.0236 0.1873 3.6898 1.3550 0.0866
19      NA   8862        120   보통주 0.0143 0.2211 4.0584 0.6047 0.0734
20      NA   3492         25   보통주 0.0013 0.2438     NA 0.2438 0.2438
   rank_PBR rank_PER rank
1         3       38    5
2        20       12    3
3        11       65    7
4        12       68    9
5        31       45    7
6        36       79   17
7         9      117   20
8        17       93   15
9        65       48   16
10       30       50    9
11        5       31    4
12       76       43   19
13       16       49    6
14       19       76   14
15        1        1    1
16       26        2    2
17       73       18   12
18       47       70   18
19       67       17   11
20       89        3   13
  # select(종목코드, 종목명, PBR, PER)
  1. min_rank() 함수를 통해 PER와 PBR 열의 순위를 구하며, rank_열 이름으로 저장합니다.
  2. 앞서 구한 두 열을 합한 후 다시 순위를 구합니다.
  3. 순위가 낮은 20종목을 선택합니다. 이는 PER와 PBR이 낮은 종목이라고 볼 수 있습니다.

7.3.3 여러 지표 결합하기

이번에는 가치지표에 해당하는 모든 지표, 즉 PER, PBR, PCR, PSR, DY를 고려한 밸류 포트폴리오를 만들어보도록 하겠다. 먼저 각 지표 별 상관관계를 살펴보도록 합니다.

value_bind_rank = value_bind %>%
  mutate(across(c(PBR, PER, PCR, PSR), min_rank, .names = "rank_{col}")) %>%
  mutate(rank_DY = min_rank(desc(DY)))

value_bind_rank %>%
  select(contains('rank')) %>%
  cor(., use = 'complete.obs') %>%
  round(., 2)
         rank_PBR rank_PER rank_PCR rank_PSR rank_DY
rank_PBR     1.00     0.49     0.42     0.74    0.41
rank_PER     0.49     1.00     0.53     0.50    0.33
rank_PCR     0.42     0.53     1.00     0.46    0.26
rank_PSR     0.74     0.50     0.46     1.00    0.36
rank_DY      0.41     0.33     0.26     0.36    1.00

PER, PBR, PCR, PSR의 경우 값이 낮을수록 가치주에 해당하지만, DY의 경우 값이 높을수록 배당수익률이 높은 가치주에 해당한다. 따라서 DY는 desc() 함수를 통해 내림차순으로 순위를 매겨줍니다. 비슷한 가치지표임에도 불구하고 서로 간의 상관관계가 꽤 낮은 지표도 있습니다. 따라서 지표를 통합적으로 고려하면 분산효과를 기대할 수도 있습니다.

value_bind_rank %>%
  mutate(rank_sum = rowSums(across(contains('rank')))) %>%
  mutate(rank_final = min_rank(rank_sum)) %>%
  filter(rank_final <= 20) 
   종목코드           종목명 시장구분   종가    시가총액     기준일   EPS
1    000140 하이트진로홀딩스    KOSPI  10050 2.33228e+11 2022-10-14  1529
2    000880             한화    KOSPI  24000 1.79901e+12 2022-10-14  9781
3    001040               CJ    KOSPI  71200 2.07740e+12 2022-10-14  8197
4    001390         KG케미칼    KOSPI  21450 2.97428e+11 2022-10-14  5749
5    002030           아세아    KOSPI 110500 2.42108e+11 2022-10-14 56785
6    002990         금호건설    KOSPI   7300 2.69761e+11 2022-10-14  4130
7    003300       한일홀딩스    KOSPI  10250 3.16037e+11 2022-10-14  1462
8    005990       매일홀딩스   KOSDAQ   7130 9.78115e+10 2022-10-14  3575
9    007860             서연    KOSPI   5270 1.23739e+11 2022-10-14  1091
10   008060             대덕    KOSPI   5990 2.03002e+11 2022-10-14    NA
11   009410         태영건설    KOSPI   4580 1.78158e+11 2022-10-14  1920
12   009970   영원무역홀딩스    KOSPI  51200 6.98142e+11 2022-10-14 19026
13   010100       한국프랜지    KOSPI   2220 6.75999e+10 2022-10-14   612
14   013580         계룡건설    KOSPI  17600 1.57184e+11 2022-10-14 17601
15   016450 한세예스24홀딩스    KOSPI   4190 1.67600e+11 2022-10-14  1026
16   034730               SK    KOSPI 206000 1.52748e+13 2022-10-14 37408
17   036530        SNT홀딩스    KOSPI  13900 2.26624e+11 2022-10-14  5329
18   078930               GS    KOSPI  45950 4.26946e+12 2022-10-14 15304
19   092230        KPX홀딩스    KOSPI  52700 2.22639e+11 2022-10-14 11573
20   267290     경동도시가스    KOSPI  20050 1.18203e+11 2022-10-14  3756
   선행EPS    BPS 주당배당금 종목구분     DY    PBR    PCR    PER    PSR
1       NA  23538        450   보통주 0.0448 0.2128 0.5890 2.6030 0.1004
2     8821  52527        750   보통주 0.0312 0.0943 0.3589 0.9098 0.0323
3    12727 151085       2300   보통주 0.0323 0.1244 0.7088 2.4128 0.0553
4       NA  34343        500   보통주 0.0233 0.1539 1.1798 0.5282 0.0531
5       NA 491013       3000   보통주 0.0271 0.1407 1.2185 1.3473 0.1211
6     2203  18202        800   보통주 0.1096 0.4132 2.0671 2.8100 0.1295
7       NA  44782        550   보통주 0.0537 0.1627 3.6368 2.9509 0.1634
8       NA  26268        150   보통주 0.0210 0.1473 0.8296 2.3741 0.0521
9       NA  21587        100   보통주 0.0190 0.1320 0.4529 1.9579 0.0451
10      NA  16976        300   보통주 0.0501 0.1455 0.6002 1.6945 0.1296
11      NA  17753        350   보통주 0.0764 0.2518 0.6446 4.2419 0.0627
12   22866 140307       2000   보통주 0.0391 0.2158 1.7153 1.0987 0.1846
13      NA   8412         90   보통주 0.0405 0.2529 1.8270 3.1296 0.0589
14   14985  78914        800   보통주 0.0455 0.2109 2.6687 1.3527 0.0564
15      NA  12066        250   보통주 0.0597 0.1796 4.0581 2.9611 0.0522
16   34166 375047       8000   보통주 0.0388 0.2290 1.7970 1.7034 0.1304
17      NA  65710        700   보통주 0.0504 0.1274 2.6537 2.1604 0.1669
18   19446 108672       2000   보통주 0.0435 0.2967 2.3272 1.7018 0.1657
19      NA 208904       3000   보통주 0.0569 0.1607 3.7481 2.9179 0.1744
20      NA  63687        875   보통주 0.0436 0.3095 1.0442 3.9533 0.0632
   rank_PBR rank_PER rank_PCR rank_PSR rank_DY rank_sum rank_final
1        60      170       20       83     179      512          3
2         3       38        7        4     364      416          2
3         7      144       31       21     348      551          6
4        20       12       69       20     510      631          7
5        12       68       74      117     423      694         12
6       316      195      136      126      12      785         18
7        28      216      283      184     115      826         19
8        18      140       45       17     556      776         15
9         9      117       12       10     611      759         14
10       17       93       21      127     131      389          1
11       99      347       25       33      44      548          5
12       65       48      112      218     247      690         11
13      100      240      120       28     231      719         13
14       58       69      197       22     169      515          4
15       39      217      320       18      78      672          9
16       77       95      117      129     254      672          9
17        8      125      196      191     130      650          8
18      146       94      155      190     198      783         17
19       25      213      291      205      92      826         19
20      161      326       58       34     197      776         15

7.4 모멘텀 전략

투자에서 모멘텀이란 주가 혹은 이익의 추세로서, 상승 추세의 주식은 지속적으로 상승하며 하락 추세의 주식은 지속적으로 하락하는 현상을 말합니다. 모멘텀의 종류는 크게 기업의 이익에 대한 추세를 나타내는 이익 모멘텀과 주가의 모멘텀에 대한 가격 모멘텀이 있으며, 이 중에서 3개월에서 12개월 가격 모멘텀을 흔히 모멘텀이라고 합니다. 즉 과거 12개월 수익률이 높았던 종목이 계속해서 상승하는 현상을 모멘텀이라 합니다.

모멘텀 효과가 발생하는 이유는 기업의 가치 변화에 대한 사람들의 반응 때문입니다. 기업의 이익이 증가하면 내재가치(펀더멘털 가치) 역시 증가하고, 이러한 가치는 즉각적으로 변합니다. 반면 주식의 가격은 늘 새로운 정보에 반응해 상승하기는 하지만, 초기에는 이익에 대한 과소 반응으로 인해 상승폭이 낮으며 그 이후 계속해서 상승합니다. 주식의 가격이 가치에 수렴하기 위해 상승하다 보면 투자자들의 주목을 끌기 마련이며, 양떼 효과로 인해 따라서 투자하는 이들이 많아집니다. 그 결과, 과잉 반응이 발생해 주가는 계속해서 상승하며 모멘텀 효과가 발생합니다. 그러나 투자자들이 기업의 가치에 비해 주가가 너무 비싸졌다고 판단하는 순간 주가는 하락하기 시작하며 반전이 이루어집니다.

7.4.1 모멘텀별 포트폴리오의 수익률

프렌치 라이브러리 데이터를 이용해 최근 12개월 수익률을 기준으로 구성된 포트폴리오의 수익률을 비교해보겠습니다.

library(frenchdata)
ff_mom = download_french_data('10 Portfolios Formed on Momentum')
New names:
New names:
New names:
New names:
New names:
New names:
New names:
• `` -> `...1`
ff_mom_vw = ff_mom$subsets$data[[1]]
ff_mom_vw %>% data_to_plot() + scale_colour_manual(values = rainbow(10))

모멘텀별 포트폴리오의 누적수익률을 확인해보면, 최근 12개월 수익률이 높을수록(Hi PRIOR) 향후에도 지속적으로 수익률이 높으며, 최근 12월 수익률이 낮을수록(Lo PRIOR) 향후에도 수익률이 낮은 ’모멘텀 현상’이 존재합니다. 이번에는 저모멘텀 대비 고모멘텀 수익률인 UMD 팩터의 수익률을 살펴보겠습니다.

ff_umd = download_french_data('Momentum Factor (Mom)')
New names:
New names:
• `` -> `...1`
ff_umd$subsets$data[[1]] %>%
  data_to_plot()

장기적으로 우상향 하는 모습을 보이지만 시장이 급락한 이후 반등할 때 모멘텀 팩터가 무너지는 현상이 발생하며, 이를 ’모멘텀 크래쉬’라 합니다.

7.4.2 모멘텀 포트폴리오 구하기

최근 12개월 수익률이 높은 주식에 투자하는 것이 훨씬 수익률이 높다는 점을 확인하였으니, 국내 종목들 중 모멘텀 주식에는 어떠한 것이 있는 확인해보도록 하겠습니다.

library(DBI)
library(RMySQL)

con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', # 위에서 설정한 root 비밀번호
  host = '127.0.0.1',
  dbname = 'stock_db' # 사용하고자 하는 스키마
)

ticker = dbGetQuery(con,
                    "select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker)
    and 종목구분 = '보통주';")
                  
price = dbGetQuery(con ,
"select 날짜, 종가, 종목코드
from kor_price
where 날짜 >= (select (select max(날짜) from kor_price) - interval 1 year);
")

dbDisconnect(con)
[1] TRUE
ret_1yr =
  price %>% select(날짜, 종목코드, 종가) %>%
  group_by(종목코드) %>%
  summarise(ret = last(종가) / first(종가) - 1)
  1. 먼저 티커 테이블과 가격 테이블을 불러옵니다. 가격 테이블은 최근 1년에 해당하는 데이터만 불러옵니다.
  2. 가격 테이블에서 종목코드 별로 그룹을 묶습니다.
  3. 최근 종가를 1년전 종가로 나누어 1년간의 수익률을 계산합니다.

이제 12개월 수익률이 높은 종목을 찾아보도록 합니다.

momentum_bind = ret_1yr %>% mutate(rank = min_rank(desc(ret))) %>%
  filter(rank <= 20) %>%
  left_join(ticker)
Joining, by = "종목코드"
momentum_bind
# A tibble: 20 × 13
   종목…¹   ret  rank 종목명  시장…²   종가 시가총액 기준일   EPS 선행EPS    BPS
   <chr>  <dbl> <int> <chr>   <chr>   <dbl>    <dbl> <chr>  <dbl>   <dbl>  <dbl>
 1 001570  2.47     5 금양    KOSPI   18650  1.08e12 2022-…   295      NA   2530
 2 003610  1.38    15 방림    KOSPI    5780  2.45e11 2022-…   191      NA   5145
 3 004690  1.83     8 삼천리  KOSPI  272500  1.10e12 2022-… 17385      NA 392735
 4 005860  1.95     7 한일사… KOSDAQ   6260  2.47e11 2022-…   183      NA   1751
 5 016710  1.41    14 대성홀… KOSPI   93400  1.50e12 2022-…  1040      NA  29257
 6 016790  5.17     2 카나리… KOSDAQ  21100  9.38e11 2022-…    NA      NA   1876
 7 025770  1.01    20 한국정… KOSDAQ  13350  5.00e11 2022-…   295      NA   6019
 8 030960  2.99     3 양지사  KOSDAQ  43100  6.89e11 2022-…    96      NA  13794
 9 043090  1.13    17 한창바… KOSDAQ   4270  1.88e11 2022-…    NA      NA    881
10 052020  2.02     6 에스티… KOSDAQ  24750  1.07e12 2022-…    NA      NA    460
11 053690  1.41    13 한미글… KOSPI   31400  3.44e11 2022-…  1537    2266  14014
12 079810  1.04    19 디이엔… KOSDAQ  10200  1.57e11 2022-…    62     886   1711
13 090710  1.55    11 휴림로… KOSDAQ   2150  3.52e11 2022-…    NA      NA    443
14 095500  2.82     4 미래나… KOSDAQ  14950  4.64e11 2022-…   940      NA   8392
15 101670  6.87     1 코리아… KOSDAQ  15500  2.98e11 2022-…    NA      NA   1470
16 179290  1.58    10 엠아이… KOSDAQ  13050  4.19e11 2022-…   392     968   2081
17 249420  1.05    18 일동제… KOSPI   26800  7.18e11 2022-…    NA      NA   6429
18 322000  1.49    12 현대에… KOSPI   56400  6.32e11 2022-…    NA    5743  28600
19 366030  1.26    16 공구우… KOSDAQ   7140  1.57e11 2022-…   581      NA   1797
20 373200  1.75     9 하인크… KOSDAQ   5410  1.02e11 2022-…    NA      NA   1872
# … with 2 more variables: 주당배당금 <dbl>, 종목구분 <chr>, and abbreviated
#   variable names ¹​종목코드, ²​시장구분

min_rank() 함수를 통해 수익률의 순위를 구하며, 모멘텀의 경우 지표가 높을수록 좋으므로 desc() 함수를 통해 내림차순으로 순위를 구합니다. 마지막으로 해당 종목들의 가격 그래프를 확인해보도록 하겠습니다.

price %>% filter(종목코드 %in% (momentum_bind %>% select(종목코드) %>% pull())) %>%
  group_by(종목코드) %>%
  slice_tail(n = 255) %>%
  ggplot(aes(x = as.Date(날짜), y = 종가)) +
  geom_line() +
  facet_wrap(. ~종목코드, scales = 'free') +
  xlab(NULL) +
  ylab(NULL) +
  theme(axis.text.x=element_blank())

7.4.3 K-Ratio

12개월 수익률 기준 모멘텀 종목들의 주가 그래프를 보면 단순히 수익률 만으로 종목을 선택할 경우 다음과 같은 종목 또한 포함됩니다.

  • 장기간 수익률이 횡보하다가 최근 주가가 급등하여 누적수익률 역시 높게 나타나는 종목
  • 이미 몇달전에 주가가 급등한 후 최근에는 하락세이지만, 누적수익률 자체는 높게 나타나는 종목

반면 좋은 모멘텀 주식이란 단순히 많이 상승한 것이 아닌, 꾸준하게 상승하는 종목이다. 하나의 예를 살펴봅시다.

동일한 누적수익률을 가진 두 종목이 있다고 가정해봅시다. A의 경우 상승폭이 작다가 최근 급등하여 누적수익률이 높아진 경우입니다. 반면 B의 경우 꾸준하게 상승하여 누적수익률이 높아진 경우입니다. 이 중 꾸준하게 상승한 B가 더 뛰어난 모멘텀 주식이라고 볼 수 있습니다. 이처럼 꾸준한 상승을 측정하기 위해 실무에서는 단순 12개월 수익률이 아닌 3~12개월 수익률을 같이 보거나, 변동성을 함께 고려하기도 합니다. 그 중 모멘텀의 꾸준함을 측정하는 지표 중 하나가 ’K-Ratio’입니다. 해당 지표는 다음과 같습니다.

\[K-Ratio = \frac{누적수익률의\ 기울기}{표준\ 오차}\] 누적수익률이 높을수록 기울기도 커져 분자는 커집니다. 또한 추세가 꾸준할수록 표준 오차가 작아 분모는 작아집니다. 따라서 추세가 꾸준하게 많이 상승할수록 K-Ratio는 증가합니다. 먼저 K-Ratio를 측정하는 법을 살펴봅시다.

library(tidyr)
library(broom)

tbl = price %>% filter(종목코드 == '005930') %>%
  mutate(ret = 종가 / lag(종가) - 1) %>%
  mutate(ret = log(1+ret)) %>%
  slice(-1) %>%
  mutate(cumret = cumsum(ret)) %>%
  mutate(id = row_number()) 

reg = lm(cumret ~ id, data = tbl)
summary(reg)

Call:
lm(formula = cumret ~ id, data = tbl)

Residuals:
     Min       1Q   Median       3Q      Max 
-0.11585 -0.02906  0.01277  0.03074  0.08558 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)  1.200e-01  6.377e-03   18.82   <2e-16 ***
id          -1.396e-03  4.458e-05  -31.31   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.04996 on 245 degrees of freedom
Multiple R-squared:  0.8001,    Adjusted R-squared:  0.7993 
F-statistic: 980.5 on 1 and 245 DF,  p-value: < 2.2e-16
  1. 먼저 삼성전자에 해당하는 데이터만 뽑아 수익률을 계산합니다.
  2. 로그수익률로 변경한 후 로그 누적수익률을 계산합니다.
  3. row_number() 함수를 통해 순서를 입력합니다.
  4. lm() 함수를 통해 \(x\) 축에는 기간, \(y\) 축에는 로그 누적수익률로 회귀분석을 실행합니한다.

결과표의 ’Estimate’는 기울기를, ’std err’는 표준 오차를 나타냅니다.

cat(coef(summary(reg))[2, 1],
    coef(summary(reg))[2, 2],
    coef(summary(reg))[2, 1] / coef(summary(reg))[2, 2]
)
-0.001395999 4.458329e-05 -31.31215

기울기와 표준오차를 추출한 후, 이 두개를 나눈 값이 K-Ratio 입니다. 이를 이용해 모든 종목의 K-Ratio를 계산하도록 하겠습니다.

library(purrr)
library(broom)

step_1 = price %>%
  group_by(종목코드) %>%
  filter(n() >= 200) %>%
  mutate(ret = 종가 / lag(종가) - 1) %>%
  mutate(ret = log(1+ret)) %>%
  slice(-1) %>%
  mutate(cumret = cumsum(ret)) %>%
  mutate(id = row_number()) 

step_2 = step_1 %>% 
  ungroup() %>%
  nest(data = -종목코드) %>%
  mutate(model = map(data, ~lm(cumret~id, data = .)),
         tidied = map(model, tidy))

k_ratio = step_2 %>%
  unnest(tidied) %>%
  filter(term == 'id') %>%
  mutate(k_ratio = estimate / `std.error`) 

# k_ratio = price %>%
#   group_by(종목코드) %>%
#   filter(n() == t) %>%
#   mutate(ret = 종가 / lag(종가) - 1) %>%
#   mutate(ret = log(1+ret)) %>%
#   slice(-1) %>%
#   mutate(cumret = cumsum(ret)) %>%
#   mutate(id = row_number()) %>%
#   do({model = lm(cumret ~ id, data = .);
#        data.frame(slope =  coef(summary(model))[2, 1], std_err =coef(summary(model))[2, 2])}) %>%
#   mutate(k_ratio = slope / std_err) %>%
#   ungroup()
  1. 상장한지 200일 이상 된 종목만 선택합니다.
  2. 수익률을 계산합니다.
  3. K-Ratio를 구합니다.

이를 토대로 K-Ratio 상위 종목을 구해보겠습니다.

k_bind = k_ratio %>% mutate(rank = min_rank(desc(k_ratio))) %>%
  filter(rank <= 20) %>%
  left_join(ticker)
Joining, by = "종목코드"
price %>% filter(종목코드 %in% (k_bind %>% select(종목코드) %>% pull())) %>%
  group_by(종목코드) %>%
  slice_tail(n = 255) %>%
  ggplot(aes(x = as.Date(날짜), y = 종가)) +
  geom_line() +
  facet_wrap(. ~종목코드, scales = 'free') +
  xlab(NULL) +
  ylab(NULL) +
  theme(axis.text.x=element_blank())

기존 단순 모멘텀이 비해 훨씬 더 꾸준하게 우상향하는 종목들이 선택되었습니다.

7.5 퀄리티 전략

벤자민 그레이엄 이후 유지되고 있는 기본적 분석 혹은 가치 투자자들의 가장 중요한 투자 지표 중 하나는 기업의 우량성(퀄리티)입니다. 벤저민 그레이엄은 종목 선정에 있어 유동 자산이 풍부하여 재무적으로 건전하고, 꾸준하게 이익을 달성하는 기업을 강조했습니다. 최고의 투자자로 꼽히는 워런 버핏의 종목 선정 기준 역시 실적의 강력한 성장 추세와 높은 자기자본 이익률로 알려져 있습니다.

그러나 어떠한 지표가 기업의 우량성을 나타내는지 한 마디로 정의하기에는 너무나 주관적이고 광범위해 쉽지 않습니다. 연구에 따르면 수익성, 성장성, 안정성이 높을 주식일수록 수익률이 높은 경향이 있습니다. 이 외에도 학계 혹은 업계에서 사용되는 우량성 관련 지표는 다음과 같이 요약할 수 있습니다.

  • 수익성: 기업이 돈을 얼마나 잘 버는가(ROE, ROA, 매출총이익률 등).
  • 수익의 안정성: 기업이 얼마나 안정적으로 돈을 버는가(ROE의 변동성 등).
  • 재무 구조: 기업의 재무 구조가 얼마나 안전한가(차입비율 등).
  • 이익의 성장: 기업의 이익 증가율이 얼마나 되는가(전년 대비 ROE 증가율 등).
  • 재무 신뢰도: 재무제표를 얼마나 신뢰할 수 있는가(회계 처리 방법 등).
  • 배당: 얼마나 주주 친화적인가(배당금, 신주발행, 자사주 매입 등.)
  • 투자: 얼마나 신사업에 투자를 하는가(총자산의 증가 등)

이 중 사람들이 가장 중요하게 여기는 것은 바로 수익성입니다. 돈을 벌지 못하는 기업은 지속될 수 없기 때문입니다. 기업의 규모가 크면 당연히 돈을 더 많이 벌기 때문에 단순히 수익의 양이 아닌, 기업의 규모에 비해 얼마를 버는지 표준화를 통해 비교해야 합니다.

지표 설명 분자 분모
ROE 자기자본이익률 당기순이익 자본
ROA 총자산이익률 당기순이익 자산
ROIC 투하자본이익률 당기순이익 투하자본
GP 매출총이익률 매출총이익 자산 혹은 자본

우량주 효과가 발생하는 이유 역시 사람들의 반응과 관계가 있습니다. 기업의 수익성이 높을 경우, 투자자들은 이익이 다시 원래 수준으로 빠르게 돌아갈 것이라 생각하지만, 실제로는 수익성이 높은 기업은 계속해서 높은 수익성을 보이는 경향이 있습니다. 반대로 기업의 수익성이 낮은 경우, 투자자들은 이익이 반등할 것이라 생각하지만 나쁜 기업은 계속해서 나쁜 경향이 있습니다.

7.5.1 수익성별 포트폴리오의 수익률

프렌치 라이브러리 데이터를 이용해 영업수익성을 기준으로 구성된 포트폴리오의 수익률을 비교해보겠습니다.

ff_op = download_french_data('Portfolios Formed on Operating Profitability')
New names:
New names:
New names:
New names:
New names:
New names:
New names:
• `` -> `...1`
ff_op_vw = ff_op$subsets$data[[1]]
ff_op_vw %>%select(date, `Lo 20`, `Qnt 2`, `Qnt 3`, `Qnt 4`, `Hi 20`) %>%
  data_to_plot()

누적수익률을 확인해보면 수익성이 높을수록(Hi 20) 향후에도 지속적으로 수익률이 높으며, 수익성이 낮을수록(Lo 20) 향후에도 수익률이 낮은 ’퀄리티 현상’이 존재합니다. 이번에는 저수익성 대비 고수익성 수익률인 QMJ 팩터의 수익률을 살펴보겠습니다.

ff_op_vw %>%
  select(date, `Lo 30`, `Hi 30`) %>%
  mutate(QMJ = `Hi 30` - `Lo 30`) %>%
  select(date, QMJ) %>%
  data_to_plot()

역시나 장기간 우상향하는 모습입니다.

7.5.2 우량성 포트폴리오 구하기

이번에는 국내 종목들 중 우량성(수익성)이 높은 종믁은 어떠한 것이 있는지 확인해보도록 하겠습니다.

library(DBI)
library(RMySQL)
library(RcppRoll)

con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', # 위에서 설정한 root 비밀번호
  host = '127.0.0.1',
  dbname = 'stock_db' # 사용하고자 하는 스키마
)

ticker = dbGetQuery(con,
                    "select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker)
    and 종목구분 = '보통주';")
                  
fs = dbGetQuery(con ,
"select * from kor_fs
where 계정 in ('당기순이익', '매출총이익', '영업활동으로인한현금흐름', '자산', '자본')
and 공시구분 = 'q';
")

dbDisconnect(con)
[1] TRUE
fs_roll = fs %>% arrange(종목코드, 계정, 기준일) %>%
  group_by(종목코드, 계정) %>%
  mutate(rollsum = roll_sum(값, n = 4, align = 'right', fill = NA)) %>%
  slice(n()) %>%
  mutate(rollsum = case_when(
    계정 %in% c('자본', '자산') ~ rollsum / 4,
    TRUE ~ rollsum
  )) %>%
  ungroup()

fs_roll
# A tibble: 11,384 × 6
   계정                     기준일        값 종목코드 공시구분 rollsum
   <chr>                    <chr>      <dbl> <chr>    <chr>      <dbl>
 1 당기순이익               2022-06-30    65 000020   q           193 
 2 매출총이익               2022-06-30   468 000020   q          1666 
 3 영업활동으로인한현금흐름 2022-06-30    48 000020   q           423 
 4 자본                     2022-06-30  3652 000020   q          3596.
 5 자산                     2022-06-30  4639 000020   q          4549 
 6 당기순이익               2022-06-30    12 000040   q          -104 
 7 매출총이익               2022-06-30    42 000040   q           158 
 8 영업활동으로인한현금흐름 2022-06-30    -2 000040   q          -130 
 9 자본                     2022-06-30   479 000040   q           492 
10 자산                     2022-06-30  1640 000040   q          1698.
# … with 11,374 more rows
  1. 티커와 재무제표 테이블을 가져오고, 수익성을 계산하는데 필요한 계정(당기순이익, 매출총이익, 영업활동으로인한현금흐름, 자산, 자본 / 분기 데이터)을 불러옵니다.
  2. 종목코드와 계정별로 그룹을 묶은 후, roll_sum() 함수를 이용해 최근 4분기 데이터의 합을 구합니다.
  3. slice(n()) 함수를 통해 그룹에서 가장 최근 데이터만 선택합니다.
  4. 자산과 자본의 경우 재무상태표 항목이므로 합이 아닌 평균을 구하며, 나머지 항목은 합을 그대로 사용합니다.

이제 각종 수익성 지표를 계산하겠습니다.

fs_roll_pivot = fs_roll %>% select(계정, 종목코드, rollsum) %>%
  pivot_wider(names_from = 계정, values_from = rollsum) %>%
  mutate(ROE = 당기순이익 / 자본,
         GPA = 매출총이익 / 자산,
         CFO = 영업활동으로인한현금흐름 / 자산)

마지막으로 수익성 지표의 순위를 구한 후, 상위 20 종목을 선택합니다.

fs_roll_pivot %>%
  mutate(across(c(ROE, GPA, CFO), .fns = ~rank(desc(.)), .names = "rank_{col}")) %>%
  mutate(rank = rank(rank_ROE + rank_GPA + rank_CFO)) %>%
  filter(rank <= 20) %>%
  left_join(ticker) %>%
  select(종목명, 종목코드, ROE, GPA, CFO)
Joining, by = "종목코드"
# A tibble: 20 × 5
   종목명           종목코드   ROE   GPA   CFO
   <chr>            <chr>    <dbl> <dbl> <dbl>
 1 DB하이텍         000990   0.447 0.495 0.339
 2 HMM              011200   0.905 0.574 0.570
 3 엠게임           058630   0.266 0.703 0.226
 4 아프리카TV       067160   0.358 0.739 0.305
 5 랩지노믹스       084650   0.520 0.649 0.386
 6 이크레더블       092130   0.285 0.647 0.259
 7 씨젠             096530   0.402 0.606 0.390
 8 위메이드맥스     101730   0.349 0.776 0.260
 9 LX세미콘         108320   0.435 0.642 0.281
10 에스디바이오센서 137310   0.466 0.482 0.301
11 파수             150900   0.330 0.699 0.387
12 휴마시스         205470   1.32  1.17  0.932
13 골프존           215000   0.343 0.674 0.295
14 삼양옵틱스       225190   0.348 0.554 0.263
15 수젠텍           253840   0.670 0.574 0.371
16 제이시스메디칼   287410   0.555 0.882 0.258
17 비올             335890   0.291 0.545 0.32 
18 넥스틴           348210   0.439 0.640 0.239
19 원티드랩         376980   0.259 0.903 0.269
20 F&F              383220   0.654 1.01  0.315

7.6 섹터 중립 포트폴리오

팩터 전략의 단점 중 하나는 선택된 종목들이 특정 섹터로 쏠리는 경우가 있다는 점입니다. 특히 과거 수익률을 토대로 종목을 선정하는 모멘텀 전략은 특정 섹터의 호황기에 동일한 섹터의 모든 종목이 함께 움직이는 경향이 있어 이러한 쏠림이 심할 수 있습니다.

실제 연구 결과를 살펴보아도 섹터 중립 포트폴리오의 수익률이 일반적인 포트폴리오의 수익률 보다 높습니다.

먼저 12개월 모멘텀을 이용한 포트폴리오 구성 방법을 다시 살펴봅시다.

library(DBI)
library(RMySQL)

con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', # 위에서 설정한 root 비밀번호
  host = '127.0.0.1',
  dbname = 'stock_db' # 사용하고자 하는 스키마
)

sector = dbGetQuery(con,
                    "select * from kor_sector
where 기준일 = (select max(기준일) from kor_sector);"
)
                  
price = dbGetQuery(con ,
"select 날짜, 종가, 종목코드
from kor_price
where 날짜 >= (select (select max(날짜) from kor_price) - interval 1 year);
")

dbDisconnect(con)
[1] TRUE
ret_1yr =
  price %>% select(날짜, 종목코드, 종가) %>%
  group_by(종목코드) %>%
  summarise(ret = last(종가) / first(종가) - 1)

ret_1yr %>% mutate(rank = min_rank(desc(ret))) %>%
  filter(rank <= 20) %>%
  left_join(sector, by = (c("종목코드" = "CMP_CD"))) %>%
  group_by(SEC_NM_KOR) %>%
  summarize(n = n()) %>%
  arrange(n) %>%
  mutate(SEC_NM_KOR = factor(SEC_NM_KOR, levels = .$SEC_NM_KOR %>% unique)) %>%
  ggplot(aes(x = SEC_NM_KOR, y = n)) +
  geom_col() +
  geom_text(aes(label = n, hjust = -1)) +
  coord_flip()

12개월 기준 모멘텀 상위 종목을 선택한 후, 섹터 테이블을 이용해 섹터별 갯수를 구합니다. 간혹 특정 섹터의 모멘텀이 매우 좋을 경우, 해당 섹터에 쏠림이 심한 경우가 있습니다. 이러한 섹터 쏠림 현상을 제거한 섹터 중립 포트폴리오를 구성해보도록 하겠습니다.

ret_1yr_neutral = ret_1yr %>%
  left_join(sector, by = (c("종목코드" = "CMP_CD"))) %>%
  group_by(SEC_NM_KOR) %>%
  mutate(scale_per_sector = scale(ret),
         scale_per_sector = ifelse(is.na(`SEC_NM_KOR`),
                                   NA, scale_per_sector)) %>%
  ungroup()
         
head(ret_1yr_neutral)
# A tibble: 6 × 7
  종목코드     ret IDX_CD CMP_KOR    SEC_NM_KOR     기준일     scale_per_sector
  <chr>      <dbl> <chr>  <chr>      <chr>          <chr>                 <dbl>
1 000020   -0.493  G35    동화약품   건강관리       2022-10-14           -0.572
2 000040   -0.399  G25    KR모터스   경기관련소비재 2022-10-14           -0.342
3 000050   -0.159  G25    경방       경기관련소비재 2022-10-14            0.523
4 000060    0.0549 G40    메리츠화재 금융           2022-10-14            1.82 
5 000070   -0.412  G30    삼양홀딩스 필수소비재     2022-10-14           -0.422
6 000080   -0.299  G30    하이트진로 필수소비재     2022-10-14           -0.238
  1. group_by() 함수를 통해 섹터별 그룹을 만들어줍니다.
  2. scale() 함수를 이용해 그룹별 정규화를 해줍니다. 정규화는 \(\frac{x-\mu}{\sigma}\)로 계산됩니다.
  3. 섹터 정보가 없을 경우 NA로 변경합니다.

위의 정규화 과정을 살펴보면, 전체 종목에서 12개월 수익률을 비교하는 것이 아닌 각 섹터별로 수익률의 강도를 비교하게 됩니다. 따라서 특정 종목의 과거 수익률이 전체 종목과 비교해서 높았더라도 해당 섹터 내에서의 순위가 낮다면, 정규화된 값은 낮아집니다.

따라서 섹터별 정규화 과정을 거친 값으로 비교 분석을 한다면, 섹터 효과가 제거된 포트폴리오를 구성할 수 있습니다.

ret_1yr_neutral %>%
  mutate(rank = min_rank(desc(scale_per_sector))) %>%
  filter(rank <= 20)
# A tibble: 20 × 8
   종목코드   ret IDX_CD CMP_KOR          SEC_NM_KOR        기준일 scale…¹  rank
   <chr>    <dbl> <chr>  <chr>            <chr>             <chr>    <dbl> <int>
 1 000230   0.833 G35    일동홀딩스       건강관리          2022-…    3.97    18
 2 001570   2.47  G15    금양             소재              2022-…    8.74     3
 3 003610   1.38  G25    방림             경기관련소비재    2022-…    6.06     9
 4 016790   5.17  G30    카나리아바이오   필수소비재        2022-…    8.68     4
 5 025770   1.01  G50    한국정보통신     커뮤니케이션서비… 2022-…    4.90    12
 6 030960   2.99  G20    양지사           산업재            2022-…    6.08     8
 7 043090   1.13  G25    한창바이오텍     경기관련소비재    2022-…    5.18    11
 8 052020   2.02  G45    에스티큐브       IT                2022-…    7.08     5
 9 056090   0.939 G35    이노시스         건강관리          2022-…    4.33    16
10 079810   1.04  G45    디이엔티         IT                2022-…    4.02    17
11 095500   2.82  G45    미래나노텍       IT                2022-…    9.60     2
12 101670   6.87  G20    코리아에스이     산업재            2022-…   13.5      1
13 179290   1.58  G35    엠아이텍         건강관리          2022-…    6.52     6
14 205470   0.746 G35    휴마시스         건강관리          2022-…    3.67    19
15 215100   0.707 G25    로보로보         경기관련소비재    2022-…    3.64    20
16 249420   1.05  G35    일동제약         건강관리          2022-…    4.70    13
17 322000   1.49  G10    현대에너지솔루션 에너지            2022-…    4.44    15
18 366030   1.26  G25    공구우먼         경기관련소비재    2022-…    5.64    10
19 373200   1.75  G45    하인크코리아     IT                2022-…    6.26     7
20 376180   0.938 G25    피코그램         경기관련소비재    2022-…    4.48    14
# … with abbreviated variable name ¹​scale_per_sector
ret_1yr_neutral %>%
  mutate(rank = min_rank(desc(scale_per_sector))) %>%
  filter(rank <= 20) %>%
  group_by(SEC_NM_KOR) %>%
  summarize(n = n()) %>%
  arrange(n) %>%
  mutate(SEC_NM_KOR = factor(SEC_NM_KOR, levels = .$SEC_NM_KOR %>% unique)) %>%
  ggplot(aes(x = SEC_NM_KOR, y = n)) +
  geom_col() +
  geom_text(aes(label = n, hjust = -1)) +
  coord_flip()

group_by() 함수를 통해 손쉽게 그룹별 중립화를 할 수 있으며, 글로벌 투자를 하는 경우에는 지역, 국가, 섹터별로도 중립화된 포트폴리오를 구성하기도 합니다.

7.7 이상치 데이터 처리 및 팩터의 결합

안정적인 퀀트 포트폴리오를 구성하기 위해서는 팩터 데이터를 어떻게 처리하여 결합할지에 대해서도 알고 있어야 하므로, 이러한 점에 대해 살펴보도록 하겠습니다.

모든 데이터 분석에서 중요한 문제 중 하나가 이상치(극단치, Outlier) 데이터를 어떻게 처리할 것인가입니다. 과거 12개월 수익률이 10배인 주식이 과연 모멘텀 관점에서 좋기만 한 주식인지, ROE가 100%를 넘는 주식이 과연 퀄리티 관점에서 좋기만 한 주식인지 고민이 되기 마련입니다. 따라서 이러한 이상치를 제외하고 분석할지, 포함해서 분석할지를 판단해야 합니다. 만일 이상치를 포함한다면 그대로 사용할 것인지, 보정해 사용할 것인지도 판단해야 합니다.

우리가 가지고 있는 PBR 데이터에서 이상치 데이터를 탐색해보도록 하겠습니다.

library(DBI)
library(RMySQL)

con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', 
  host = '127.0.0.1',
  dbname = 'stock_db'
)

value = dbGetQuery(con ,
"select * from kor_value
where 기준일 = (select max(기준일) from kor_value);
")

dbDisconnect(con)
[1] TRUE
PBR = value %>%
  mutate(값 = ifelse(값 <=0, NA, 값)) %>%
  filter(지표 == 'PBR') %>%
  pivot_wider(names_from = '지표', values_from = '값') %>%
  select(-기준일)

PBR %>% summarize(max_pbr = max(PBR, na.rm = T), min_pbr = min(PBR, na.rm = T))
# A tibble: 1 × 2
  max_pbr min_pbr
    <dbl>   <dbl>
1    40.4  0.0363

먼저 밸류 테이블을 불러온 후 PBR 데이터만 선택합니다. PBR의 최대값과 최소값을 확인해보면 값이 매우 큰 것을 확인할 수 있습니다.

PBR %>%
  ggplot(aes(x = PBR)) +
  geom_histogram(bins = 100)
Warning: Removed 4 rows containing non-finite values (`stat_bin()`).

국내 종목들의 PBR을 히스토그램으로 그려보면 오른쪽으로 꼬리가 매우 긴 분포를 보입니다. 이는 PBR이 극단적으로 큰 이상치 데이터가 있기 때문입니다. 이처럼 모든 팩터 데이터에는 극단치가 있기 마련이며, 이를 처리하는 방법을 알아보도록 하겠습니다.

7.7.1 트림(Trim): 이상치 데이터 삭제

트림은 이상치 데이터를 삭제하는 방법입니다. 위의 예제에서 이상치에 해당하는 상하위 1% 데이터를 삭제하겠습니다.

PBR %>%
  mutate(PBR = ifelse(percent_rank(PBR) > 0.99, NA, PBR),
         PBR = ifelse(percent_rank(PBR) < 0.01, NA, PBR)) %>%
  ggplot(aes(x = PBR)) +
  geom_histogram(bins = 100)
Warning: Removed 50 rows containing non-finite values (`stat_bin()`).

percent_rank() 함수를 통해 백분위를 구한 후 상하위 1%에 해당하는 데이터를 제외한 데이터만 선택합니다. 결과적으로 지나치게 PBR이 낮은 종목과 높은 종목은 제거되어 \(x\)축의 스케일이 많이 줄어든 모습입니다.

평균이나 분산 같이 통계값을 구하는 과정에서는 이상치 데이터를 제거하는 것이 바람직할 수 있습니다. 그러나 팩터를 이용해 포트폴리오를 구하는 과정에서 해당 방법은 조심스럽게 사용되어야 합니다. 데이터의 손실이 발생하게 되며, 제거된 종목 중 정말로 좋은 종목이 있을 수도 있기 때문입니다.

7.7.2 윈저라이징(Winsorizing): 이상치 데이터 대체

이상치 데이터를 다른 데이터로 대체하는 윈저라이징 방법을 사용할 수도 있습니다. 예를 들어 상위 1%를 초과하는 데이터는 1% 값으로 대체하며, 하위 1% 미만의 데이터는 1% 데이터로 대체합니다. 즉, 좌우로 울타리를 쳐놓고 해당 범위를 넘어가는 값을 강제로 울타리에 맞춰줍니다.

PBR %>%
  mutate(PBR = ifelse(percent_rank(PBR) > 0.99,
                      quantile(.$PBR, 0.99, na.rm = TRUE), PBR),
         PBR = ifelse(percent_rank(PBR) < 0.01,
                      quantile(.$PBR, 0.01, na.rm = TRUE), PBR)) %>%
  ggplot(aes(x = PBR)) +
  geom_histogram(bins = 100)
Warning: Removed 4 rows containing non-finite values (`stat_bin()`).

이번에는 값이 상하위 1%를 벗어나는 경우, 1%에 해당하는 값으로 대체하였습니다. 그림을 살펴보면 축 양 끝부분의 막대(붉은색)가 길어진 것을 확인할 수 있습니다.

7.7.3 팩터의 결합방법

앞서 밸류 지표의 결합, 퀄리티 지표의 결합, 마법공식 포트폴리오를 구성할 때는 단순히 순위를 더하는 방법을 사용했습니다. 물론 투자 종목수가 얼마 되지 않거나, 개인 투자자의 입장에서는 이러한 방법이 가장 단순하면서도 효과적일수 있습니다. 그러나 전문투자자가 포트폴리오를 구성하거나 팩터를 분석하는 업무를 할 경우 이처럼 단순히 순위를 더하는 방법은 여러 가지 문제를 안고 있습니다.

각 밸류 지표의 순위를 구한 후 히스토그램으로 나타내보도록 하겠습니다.

library(DBI)
library(RMySQL)

con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', # 위에서 설정한 root 비밀번호
  host = '127.0.0.1',
  dbname = 'stock_db' # 사용하고자 하는 스키마
)

ticker = dbGetQuery(con,
                    "select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker)
    and 종목구분 = '보통주';")
                  
value = dbGetQuery(con ,
"select * from kor_value
where 기준일 = (select max(기준일) from kor_value);
")

dbDisconnect(con)
[1] TRUE
value = value %>%
  mutate(값 = ifelse(값 <=0, NA, 값)) %>%
  pivot_wider(names_from = '지표', values_from = '값') %>%
  select(-기준일) %>%
  mutate(across(c(PBR, PER, PCR, PSR), min_rank, .names = "rank_{col}")) %>%
  mutate(rank_DY = min_rank(desc(DY)))

value %>%
  select(종목코드, contains('rank_')) %>%
  pivot_longer(-종목코드) %>%
  ggplot(aes(x = value)) +
  geom_histogram() +
  facet_wrap(name ~. , ncol = 1)
`stat_bin()` using `bins = 30`. Pick better value with `binwidth`.
Warning: Removed 2729 rows containing non-finite values (`stat_bin()`).

그림에서 알 수 있듯이 순위를 구하는 것의 가장 큰 장점은 극단치로 인한 효과가 사라진다는 점과 균등한 분포를 가진다는 점입니다. 그러나 각 지표의 \(x\)축을 보면 최댓값이 서로 다릅니다. 이는 지표별 결측치로 인해 유효 데이터의 갯수가 달라 나타나는 현상입니다.

value %>%
  select(종목코드, contains('rank_')) %>%
  pivot_longer(-종목코드) %>%
  group_by(name) %>%
  summarize(na_count = sum(is.na(value)))
# A tibble: 5 × 2
  name     na_count
  <chr>       <int>
1 rank_DY      1130
2 rank_PBR        4
3 rank_PCR      828
4 rank_PER      681
5 rank_PSR       86

밸류 지표 별 NA 개수를 확인해보면 그 결과가 모두 다르며, 특히 배당 수익률의 경우 절반 정도가 NA 데이터입니다. 따라서 서로 다른 범위의 분포를 단순히 합치는 것은 좋은 방법이 아닙니다. 예를 들어 A, B, C, D 팩터에 각각 비중을 25%, 25%, 25%, 25% 부여해 포트폴리오를 구성한다고 가정해봅시다. 각 순위는 분포의 범위가 다르므로, 순위와 비중의 가중평균을 통해 포트폴리오를 구성하면 왜곡된 결과를 발생시킵니다.

이러한 문제를 해결하는 가장 좋은 방법은 순위를 구한 후 이를 Z-Score로 정규화하는 것입니다.

value_z_score = value %>%
  select(1:6) %>%
  mutate(across(c(PBR, PER, PCR, PSR), min_rank, .names = "rank_{col}")) %>%
  mutate(rank_DY = min_rank(desc(DY))) %>%
  mutate(across(c(rank_PBR, rank_PER, rank_PCR, rank_PSR, rank_DY), scale, .names = "z_{col}")) 

value_z_score %>%
  select(종목코드, contains('z_')) %>%
  pivot_longer(-종목코드) %>%
  ggplot(aes(x = value)) +
  geom_histogram() +
  facet_wrap(name ~. , ncol = 1)
`stat_bin()` using `bins = 30`. Pick better value with `binwidth`.
Warning: Removed 2729 rows containing non-finite values (`stat_bin()`).

앞서 구해진 순위에 scale 함수를 통해 정규화를 해줍니다. 기본적으로 순위의 분포가 가진 극단치 효과가 사라지는 점과 균등 분포의 장점을 유지하고 있으며, 분포의 범위 역시 거의 동일하게 바뀌었습니다. 이처럼 여러 팩터를 결합해 포트폴리오를 구성하고자 하는 경우, 먼저 각 팩터(지표)별 순위를 구한 후 이를 정규화한 뒤 더해야 왜곡 효과가 제거되어 안정적인 포트폴리오가 됩니다.

\[Z-Score(Rank(Factor\ A)) + Z-Score(Rank(Factor\ B)) + \dots + Z-Score(Rank(Factor\ N))\]

7.8 멀티팩터 포트폴리오

앞에서 배웠던 팩터 이론들과 결합 방법들을 응용해 멀티팩터 포트폴리오를 구성해봅시다. 각 팩터에 사용되는 지표는 다음과 같습니다.

  • 퀄리티: 자기자본이익률(ROE), 매출총이익(GPA), 영업활동현금흐름(CFO)
  • 밸류: PER, PBR, PSR, PCR, DY
  • 모멘텀: 12개월 수익률, K-Ratio
library(DBI)
library(RMySQL)

# 연결
con = dbConnect(
  drv = MySQL(),
  user = 'root',
  password = '1234', 
  host = '127.0.0.1',
  dbname = 'stock_db'
)

# 티커
ticker = dbGetQuery(con,
                    "select * from kor_ticker
where 기준일 = (select max(기준일) from kor_ticker)
    and 종목구분 = '보통주';")
      
# 주가            
price = dbGetQuery(con ,
"select 날짜, 종가, 종목코드
from kor_price
where 날짜 >= (select (select max(날짜) from kor_price) - interval 1 year);
")

# 밸류
value = dbGetQuery(con ,
"select * from kor_value
where 기준일 = (select max(기준일) from kor_value);
")

# 재무제표
fs = dbGetQuery(con ,
"select * from kor_fs
where 계정 in ('당기순이익', '매출총이익', '영업활동으로인한현금흐름', '자산', '자본')
and 공시구분 = 'q';
")

# 섹터
sector = dbGetQuery(con,
                    "select * from kor_sector
where 기준일 = (select max(기준일) from kor_sector);"
)

dbDisconnect(con)
[1] TRUE

티커, 섹터, 주가, 재무제표, 가치지표 데이터를 불러옵니다.

value = value %>%
  mutate(값 = ifelse(값 <=0, NA, 값)) %>%
  pivot_wider(names_from = '지표', values_from = '값') %>%
  select(-기준일) %>%
  mutate(across(c(PBR, PER, PCR, PSR), min_rank, .names = "rank_{col}")) %>%
  mutate(rank_DY = min_rank(desc(DY)))

가치지표를 핸들링합니다.

library(RcppRoll)

fs_roll = fs %>% arrange(종목코드, 계정, 기준일) %>%
  group_by(종목코드, 계정) %>%
  mutate(rollsum = roll_sum(값, n = 4, align = 'right', fill = NA)) %>%
  slice(n()) %>%
  mutate(rollsum = case_when(
    계정 %in% c('자본', '자산') ~ rollsum / 4,
    TRUE ~ rollsum
  )) %>%
  ungroup()

fs_roll_pivot = fs_roll %>% select(계정, 종목코드, rollsum) %>%
  pivot_wider(names_from = 계정, values_from = rollsum) %>%
  mutate(ROE = 당기순이익 / 자본,
         GPA = 매출총이익 / 자산,
         CFO = 영업활동으로인한현금흐름 / 자산)

퀄리티 지표를 계산하기 위해 TTM 기준 ROE, GPA, CFO를 계산합니다.

ret_1yr =
  price %>% select(날짜, 종목코드, 종가) %>%
  group_by(종목코드) %>%
  slice_tail(n = 255) %>%
  summarise(ret = last(종가) / first(종가) - 1)

k_ratio = price %>%
  group_by(종목코드) %>%
  filter(n() >= 200) %>%
  mutate(ret = 종가 / lag(종가) - 1) %>%
  mutate(ret = log(1+ret)) %>%
  slice(-1) %>%
  mutate(cumret = cumsum(ret)) %>%
  mutate(id = row_number())  %>%
  ungroup() %>%
  nest(data = -종목코드) %>%
  mutate(model = map(data, ~lm(cumret~id, data = .)),
         tidied = map(model, tidy)) %>%
  unnest(tidied) %>%
  filter(term == 'id') %>%
  mutate(k_ratio = estimate / `std.error`) 

최근 12개월 수익률과 K-Ratio를 계산합니다. 이제 모든 테이블을 하나로 합치도록 합니다.

library(tibble)

data_bind  = 
  ticker %>%
  left_join(sector, by = c('종목코드' = 'CMP_CD')) %>%
  left_join(fs_roll_pivot %>% select(종목코드, ROE, GPA, CFO)) %>%
  left_join(value) %>%
  left_join(ret_1yr) %>%
  left_join(k_ratio %>% select(종목코드, k_ratio)) 
Joining, by = "종목코드"
Joining, by = "종목코드"
Joining, by = "종목코드"
Joining, by = "종목코드"
data_bind = data_bind %>%
  mutate(SEC_NM_KOR = replace_na(SEC_NM_KOR, '기타')) %>% as_tibble()

data_bind
# A tibble: 2,294 × 30
   종목코드 종목명    시장…¹  종가 시가총액 기준…²    EPS 선행EPS    BPS 주당…³
   <chr>    <chr>     <chr>  <dbl>    <dbl> <chr>   <dbl>   <dbl>  <dbl>  <dbl>
 1 000020   동화약품  KOSPI   8650  2.42e11 2022-1…   647      NA  12534    180
 2 000040   KR모터스  KOSPI    599  5.76e10 2022-1…    NA      NA    385      0
 3 000050   경방      KOSPI  10800  2.96e11 2022-1…   872      NA  30033    125
 4 000060   메리츠화… KOSPI  29600  3.37e12 2022-1…  5768    6808  22086    620
 5 000070   삼양홀딩… KOSPI  63800  5.46e11 2022-1… 30711      NA 226314   3000
 6 000080   하이트진… KOSPI  24650  1.73e12 2022-1…  1031    1984  15657    800
 7 000100   유한양행  KOSPI  55000  4.03e12 2022-1…  1496    1673  28297    400
 8 000120   CJ대한통… KOSPI  81000  1.85e12 2022-1…  1841   10250 178766      0
 9 000140   하이트진… KOSPI  10050  2.33e11 2022-1…  1529      NA  23538    450
10 000150   두산      KOSPI  78600  1.30e12 2022-1… 11890   12800 121631   2000
# … with 2,284 more rows, 20 more variables: 종목구분 <chr>, IDX_CD <chr>,
#   CMP_KOR <chr>, SEC_NM_KOR <chr>, 기준일.y <chr>, ROE <dbl>, GPA <dbl>,
#   CFO <dbl>, DY <dbl>, PBR <dbl>, PCR <dbl>, PER <dbl>, PSR <dbl>,
#   rank_PBR <int>, rank_PER <int>, rank_PCR <int>, rank_PSR <int>,
#   rank_DY <int>, ret <dbl>, k_ratio <dbl>, and abbreviated variable names
#   ¹​시장구분, ²​기준일.x, ³​주당배당금

테이블을 합친 후, 섹터 정보가 없는 경우 ’기타’를 입력합니다.

이번에는 각 섹터별로 아웃라이어를 제거한 후 순위와 Z-Score를 구하도록 하겠습니다. 첫번째로 퀄리티 지표의 Z-Score를 계산합니다.

z_quality = data_bind %>% select(종목코드, SEC_NM_KOR, ROE, GPA, CFO) %>%
  group_by(SEC_NM_KOR) %>%
  mutate(across(c(ROE, GPA, CFO), .fns = ~min_rank(desc(.)), .names = "rank_{col}")) %>%  
  mutate(across(c(rank_ROE, rank_GPA, rank_CFO), .fns = ~scale(.), .names = "z_{col}")) %>%
  mutate(z_quality = rowSums(across(contains('z_rank')))) %>%
  ungroup()

data_bind = data_bind %>%
  left_join(z_quality %>% select(종목코드, z_quality))
Joining, by = "종목코드"
data_bind
# A tibble: 2,294 × 31
   종목코드 종목명    시장…¹  종가 시가총액 기준…²    EPS 선행EPS    BPS 주당…³
   <chr>    <chr>     <chr>  <dbl>    <dbl> <chr>   <dbl>   <dbl>  <dbl>  <dbl>
 1 000020   동화약품  KOSPI   8650  2.42e11 2022-1…   647      NA  12534    180
 2 000040   KR모터스  KOSPI    599  5.76e10 2022-1…    NA      NA    385      0
 3 000050   경방      KOSPI  10800  2.96e11 2022-1…   872      NA  30033    125
 4 000060   메리츠화… KOSPI  29600  3.37e12 2022-1…  5768    6808  22086    620
 5 000070   삼양홀딩… KOSPI  63800  5.46e11 2022-1… 30711      NA 226314   3000
 6 000080   하이트진… KOSPI  24650  1.73e12 2022-1…  1031    1984  15657    800
 7 000100   유한양행  KOSPI  55000  4.03e12 2022-1…  1496    1673  28297    400
 8 000120   CJ대한통… KOSPI  81000  1.85e12 2022-1…  1841   10250 178766      0
 9 000140   하이트진… KOSPI  10050  2.33e11 2022-1…  1529      NA  23538    450
10 000150   두산      KOSPI  78600  1.30e12 2022-1… 11890   12800 121631   2000
# … with 2,284 more rows, 21 more variables: 종목구분 <chr>, IDX_CD <chr>,
#   CMP_KOR <chr>, SEC_NM_KOR <chr>, 기준일.y <chr>, ROE <dbl>, GPA <dbl>,
#   CFO <dbl>, DY <dbl>, PBR <dbl>, PCR <dbl>, PER <dbl>, PSR <dbl>,
#   rank_PBR <int>, rank_PER <int>, rank_PCR <int>, rank_PSR <int>,
#   rank_DY <int>, ret <dbl>, k_ratio <dbl>, z_quality <dbl>, and abbreviated
#   variable names ¹​시장구분, ²​기준일.x, ³​주당배당금

두번째로 밸류 지표의 Z-Score를 계산합니다.

z_value =
  data_bind %>% select(종목코드, SEC_NM_KOR, DY, PBR, PCR, PER, PSR) %>%
  group_by(SEC_NM_KOR) %>%
  mutate(rank_DY = min_rank(desc(DY))) %>%
  mutate(across(c(PBR, PCR, PER, PSR), .fns = ~min_rank(.), .names = "rank_{col}")) %>%  
  mutate(across(c(rank_DY, rank_PBR, rank_PCR, rank_PER, rank_PSR), .fns = ~scale(.), .names = "z_{col}")) %>%
  mutate(z_value = rowSums(across(contains('z_rank')))) %>%
  ungroup()

data_bind = data_bind %>%
  left_join(z_value %>% select(종목코드, z_value))
Joining, by = "종목코드"

마지막으로 모멘텀 지표의 Z-Score를 구합니다.

z_momentum =
  data_bind %>% select(종목코드, SEC_NM_KOR, ret, k_ratio) %>%
  group_by(SEC_NM_KOR) %>%
  mutate(across(c(ret, k_ratio), .fns = ~min_rank(desc(.)), .names = "rank_{col}")) %>%  
  mutate(across(c(rank_ret, rank_k_ratio), .fns = ~scale(.), .names = "z_{col}")) %>%
  mutate(z_momentum = rowSums(across(contains('z_rank')))) %>%
  ungroup()

data_bind = data_bind %>%
  left_join(z_momentum %>% select(종목코드, z_momentum))
Joining, by = "종목코드"

각 팩터의 분포를 시각화해보도록 하겠습니다.

data_bind %>%
  select(종목코드, contains('z_')) %>%
  pivot_longer(-종목코드) %>%
  ggplot(aes(x = value)) +
  geom_histogram() +
  facet_grid(name ~ .)
`stat_bin()` using `bins = 30`. Pick better value with `binwidth`.
Warning: Removed 1768 rows containing non-finite values (`stat_bin()`).

각각 퀄리티 지표는 3개, 밸류 지표는 5개, 모멘텀 지표는 2개 기준을 이용해 계산했습니다. 그림에서 알 수 있듯이 기준을 많이 사용할 수록 Z-Score가 넓게 퍼져있는 모습을 보이며, 각 팩터별 분포가 동일하지 않습니다. 따라서 다시 Z-Score를 계산해 분포의 넓이를 비슷하게 맞춰주도록 합니다.

library(magrittr)

Attaching package: 'magrittr'
The following object is masked from 'package:purrr':

    set_names
The following object is masked from 'package:tidyr':

    extract
data_bind_final  = data_bind %>%
  select(종목코드, z_quality, z_value, z_momentum) %>%
  mutate(across(c(z_quality, z_value, z_momentum), .fns = ~scale(.))) %>%
  set_colnames(c('종목코드', 'quality', 'value', 'momentum'))

data_bind_final %>%
  pivot_longer(-종목코드) %>%
  ggplot(aes(x = value)) +
  geom_histogram() +
  facet_grid(name ~ .)
`stat_bin()` using `bins = 30`. Pick better value with `binwidth`.
Warning: Removed 1768 rows containing non-finite values (`stat_bin()`).

재계산된 Z-Score의 분포의 넓이를 살펴보면 이전에 비해 훨씬 비슷해진 것을 알 수 있습니다. 각 팩터들 간의 상관관계를 살펴보겠습니다.

data_bind_final %>%
  select(-종목코드) %>%
  cor(., use = 'complete.obs') %>%
  round(., 2)
         quality value momentum
quality     1.00  0.08     0.08
value       0.08  1.00    -0.15
momentum    0.08 -0.15     1.00

각 팩터간 상관관계가 매우 낮으며, 여러 팩터를 동시에 고려함으로서 분산효과를 기대할 수 있습니다. 이제 계산된 팩터들을 토대로 최종 포트폴리오를 구성해보도록 하겠습니다.

wts = c(0.3, 0.3, 0.3)

data_bind_final = data_bind_final %>%
  column_to_rownames('종목코드') %>%
  multiply_by(wts) %>% 
  mutate(qvm = rowSums(.)) %>%
  rownames_to_column(var = '종목코드') %>%
  select(종목코드, qvm)
  
data_bind = data_bind %>%
  left_join(data_bind_final) %>%
  mutate(invest = ifelse(min_rank(qvm) <= 20, 'Y', 'N'))
Joining, by = "종목코드"
data_bind %>%
  filter(invest == 'Y')
# A tibble: 20 × 35
   종목코드 종목명   시장…¹   종가 시가총액 기준…²    EPS 선행EPS    BPS 주당…³
   <chr>    <chr>    <chr>   <dbl>    <dbl> <chr>   <dbl>   <dbl>  <dbl>  <dbl>
 1 000700   유수홀…  KOSPI    6350  1.65e11 2022-1…   991      NA  13108    400
 2 001120   LX인터…  KOSPI   42750  1.66e12 2022-1…  9733   11746  49349   2300
 3 005010   휴스틸   KOSPI    5810  2.28e11 2022-1…   987      NA  15984    160
 4 009970   영원무…  KOSPI   51200  6.98e11 2022-1… 19026   22866 140307   2000
 5 017670   SK텔레콤 KOSPI   49700  1.09e13 2022-1…  7191    5167  53218   3295
 6 030200   KT       KOSPI   34800  9.09e12 2022-1…  5759    5188  63512   1910
 7 036710   심텍홀…  KOSDAQ   3140  1.52e11 2022-1…   695      NA   4051     50
 8 049070   인탑스   KOSDAQ  27800  4.78e11 2022-1…  4436    4244  32446    470
 9 058860   KTis     KOSPI    2430  8.46e10 2022-1…   767      NA   5909    100
10 065510   휴비츠   KOSDAQ  10000  1.19e11 2022-1…   848    2383   8297    200
11 078930   GS       KOSPI   45950  4.27e12 2022-1… 15304   19446 108672   2000
12 084650   랩지노…  KOSDAQ   7110  2.42e11 2022-1…  2497      NA   4747    300
13 093050   LF       KOSPI   15450  4.52e11 2022-1…  4169      NA  46904    600
14 094970   제이엠티 KOSDAQ   3115  5.22e10 2022-1…   908      NA   4505    150
15 124560   태웅로…  KOSDAQ   4805  1.85e11 2022-1…  1726      NA   3292    100
16 200880   서연이화 KOSPI    6630  1.79e11 2022-1…   973      NA  24588    150
17 205470   휴마시스 KOSDAQ  17850  6.11e11 2022-1…  4422      NA   5908    200
18 225220   제놀루션 KOSDAQ   9700  9.30e10 2022-1…  3626      NA   9083    400
19 306200   세아제강 KOSPI  142000  4.03e11 2022-1… 32640   64527 242703   3500
20 319660   피에스…  KOSDAQ  17150  2.48e11 2022-1…  2615    2896   9888    600
# … with 25 more variables: 종목구분 <chr>, IDX_CD <chr>, CMP_KOR <chr>,
#   SEC_NM_KOR <chr>, 기준일.y <chr>, ROE <dbl>, GPA <dbl>, CFO <dbl>,
#   DY <dbl>, PBR <dbl>, PCR <dbl>, PER <dbl>, PSR <dbl>, rank_PBR <int>,
#   rank_PER <int>, rank_PCR <int>, rank_PSR <int>, rank_DY <int>, ret <dbl>,
#   k_ratio <dbl>, z_quality <dbl>, z_value <dbl>, z_momentum <dbl>, qvm <dbl>,
#   invest <chr>, and abbreviated variable names ¹​시장구분, ²​기준일.x,
#   ³​주당배당금
  1. 각 팩터별 비중을 리스트로 만들며, 0.3으로 동일한 비중을 입력합니다다. 비중을 [0.2, 0.4, 0.4]와 같이 팩터별로 다르게 줄 수도 있으며, 이는 어떠한 팩터를 더욱 중요하게 생각하는지 혹은 더욱 좋게 보는지에 따라 조정이 가능합니다.
  2. 팩터별 Z-Score와 비중의 곱을 구한 후 이를 합합니다.
  3. 기존 테이블(data_bind)과 합칩니다.
  4. 최종 Z-Score의 합(qvm) 기준 순위가 1~20인 경우는 투자 종목에 해당하므로 ‘Y’, 그렇지 않으면 ’N’으로 표시합니다.

최종 선택된 종목들을 보면 전반적으로 퀄리티가 높고, 밸류에이션이 낮으며, 최근 수익률이 높습니다. 물론 특정 팩터(예: 모멘텀)가 좋지 않아도 다른 팩터(예: 밸류)가 지나치게 좋아 선택되는 경우도 있습니다. 이제 선택된 종목들과 그렇지 않은 종목들간의 특성을 그림으로 표현해보겠습니다.

data_bind %>%
  select(ROE, GPA, CFO, invest) %>%
  na.omit() %>%
  pivot_longer(-invest) %>%
  group_by(name) %>%
  mutate(rank = min_rank(desc(value))) %>%
  ggplot(aes(x = rank, y = 1, shape = invest, color = invest, alpha = invest)) +
  geom_point(size = 3) +
  scale_color_manual(values=c("grey", "red")) +
  facet_grid(name~.) 
Warning: Using alpha for a discrete variable is not advised.

  1. 퀄리티 지표가 포함된 데이터를 선택한다.
  2. 각 지표(name)별 그룹을 묶은 후 순위를 계산합니다.
  3. 그림으로 나타냅니다.

붉은색 ▲ 마크는 투자하는 종목, 회색 ● 마크는 투자하지 않는 종목입니다. 전반적으로 멀티팩터 기준으로 선정된 종목들의 퀄리티 순위가 높음을 알 수 있습니다.

이번에는 동일한 방법으로 밸류 지표의 차이를 살펴보겠습니다.

data_bind %>%
  select(DY, PBR, PER, PCR, PSR, invest) %>%
  na.omit() %>%
  pivot_longer(-invest) %>%
  group_by(name) %>%
  mutate(rank = 
           ifelse(name == 'DY', min_rank(desc(value)), min_rank(value))) %>%
  ggplot(aes(x = rank, y = 1, shape = invest, color = invest, alpha = invest)) +
  geom_point(size = 3) +
  scale_color_manual(values=c("grey", "red")) +
  facet_grid(name~.) 
Warning: Using alpha for a discrete variable is not advised.

밸류 지표 역시 멀티팩터 기준으로 선정된 종목들의 순위가 높습니다. 그러나 사용되는 지표가 많은 만큼 일부 지표에서는 순위가 낮은 종목들이 선정되기도 합니다.

이번에는 모멘텀 지표의 차이를 살펴보겠습니다.

data_bind %>%
  select(ret, k_ratio, invest) %>%
  na.omit() %>%
  pivot_longer(-invest) %>%
  group_by(name) %>%
  mutate(rank = min_rank(desc(value))) %>%
  ggplot(aes(x = rank, y = 1, shape = invest, color = invest, alpha = invest)) +
  geom_point(size = 3) +
  scale_color_manual(values=c("grey", "red")) +
  facet_grid(name~.) 
Warning: Using alpha for a discrete variable is not advised.

모멘텀 지표 역시 멀티팩터 기준으로 선정된 종목들의 순위가 높습니다.

이처럼 멀티팩터 기준으로 종목을 선정할 경우 각 팩터가 골고루 좋은 종목을 선택할 수 있습니다. 이 외에도 팩터를 만들 수 있는 기본 데이터가 모두 있으므로 최근 적자기업 제외, 매출 증가 등 다양한 전략을 추가할 수도 있습니다.