백테스팅에 사용할 investing.com 주식 데이터 정제(data cleansing)

6 minute read

백테스팅(backtesting)은 과거 데이터로 퀀트 전략을 시뮬레이션한다. Investing.com이 과거 데이터를 얻을 수 있는 곳 중 한 곳이다.

데이터를 받아서 바로 쓸 수 있으면 좋다. 하지만 내 입맛에 딱 맞아떨어지는 데이터는 드물어서 받은 데이터를 가공해야 하는 방법을 익혀야 한다.

데이터 정제(Data cleansing)는 레코드 세트, 테이블 또는 데이터베이스에서 손상되거나 부정확한 레코드를 감지 및 수정(또는 제거)하는 프로세스이며 데이터의 불완전하거나 부정확하거나 관련 없는 부분을 식별한 다음 교체, 수정, 또는 더럽거나 거친 데이터를 삭제한다. 데이터 정리는 데이터 랭글링 도구와 대화식으로 수행되거나 스크립팅 또는 데이터 품질 방화벽을 통한 일괄 처리로 수행될 수 있다

데이터 정제 - ko.wikipedia.org

이런 걸 데이터 정제라고 한다. investing.com에서 받은 데이터를 정제하는 방법을 알아보자.

investing.com에서 데이터 받기

investing.com에서는 과거 데이터(historical data)를 조회할 수 있다. 데이터 최대 정밀도는 일별 시세다. 더 높은 정밀도를 원한다면 다른 곳을 알아봐야 하겠지만 그게 아니라면 다양한 데이터를 제공해서 investing.com이면 충분하다.

Kodex 코스피(226490) ETF의 과거 데이터는 Samsung KODEX KOSDAQ 150 ETF (229200) 페이지에서 볼 수 있다. 226490와 같은 종목 코드로 검색하면 쉽게 해당 데이터를 볼 수 있다. 다운로드 버튼이 안 보여서 이거 스크레이핑을 해야 하나 고민이 된다면 우선 로그인부터 하자. 로그인하면 download data 버튼이 보인다.

다운로드 받은 데이터 힐끔보기

"Date","Price","Open","High","Low","Vol.","Change %"
"09/19/2023","13,750","13,830","13,925","13,695","3.81M","-0.69%"

CSV(Comma-separated values) 파일이다. 날짜 포맷이 ISO 8601이 아니다. 가격에 관련된 컬럼은 모두 천 단위 콤마가 있다. 거래량에는 M, K 같은 단위 문자가 있다. 등락률은 백분율이다.

Elixir 프로그래밍 언어와 Explorer 라이브러리를 사용해서 데이터를 로드하고 정제한다. Elixir는 Erlang VM 위에서 실행하는 함수형 프로그래밍 언어다. Explorer 라이브러리는 dplyr 라이브러리에 영향을 받은 DataFrame 라이브러리다. 예제 코드는 Elixir지만 다른 언어의 대세 DataFrame 라이브러리를 보면 함수 세트가 비슷해서 다른 언어로 구현할 때도 참고할 수 있지 않을까 예상한다.

이제 하나씩 정제할 차례다.

날짜 데이터 정제

alias Explorer.DataFrame
alias Explorer.Series

Kino.FS.file_path("226490.csv")
|> DataFrame.from_csv!()
|> DataFrame.mutate_with(fn lf ->
  %{
    "Date" => lf["Date"] |> Series.strptime("%m/%d/%Y") |> Series.cast(:date)
}
end)
|> DataFrame.arrange_with(& &1["Date"])

csv 파일을 읽어서 data frame을 만들고 data 컬럼 데이터를 변경한다. Explorer.Series.strptime/2 함수를 사용해 날짜 형식을 파싱한다. 파싱 결과가 microsecond 단위라서 Explorer.Series.cast/2 함수를 사용해 date 타입으로 변경한다. DataFrame.arrange_with/2 함수를 사용해 날짜 오름차순으로 정렬한다.

Date Price Open High Low Vol. Change
2015-08-25 “18,510” “18,380” “18,645” “18,065” 359.48K 1.12%
2015-08-26 “18,925” “18,455” “18,925” “18,400” 256.93K 2.24%
2015-08-27 “19,110” “19,135” “19,175” “19,000” 304.18K 0.98%
2015-08-28 “19,395” “19,335” “19,400” “19,285” 549.82K 1.49%
2015-08-31 “19,365” “19,390” “19,415” “19,260” 297.94K -0.15%
2015-09-01 “19,170” “19,355” “19,410” “19,155” 247.38K -1.01%
2015-09-02 “19,160” “18,865” “19,200” “18,865” 255.38K -0.05%
2015-09-03 “19,175” “19,255” “19,285” “19,140” 258.26K 0.08%
2015-09-04 “18,850” “19,210” “19,220” “18,820” 326.93K -1.69%
2015-09-07 “18,840” “18,870” “18,935” “18,725” 315.61K -0.05%

date 타입으로 변경했고 잘 나온다. 이제 Price, Open, High, Low 컬럼값을 integer 타입으로 변경할 차례다

Price, Open, High, Low 숫자 데이터 정제

0.7.1 버전에서 Series.replace/3DataFrame.mutate_with/2 함수 안에서 사용하지 못한다. 이 문제를 고친 Add replace/3 expressions (#719) PR이 머지됐는데, 다음 버전 릴리즈가 아직 안 됐다.

alias Explorer.DataFrame
alias Explorer.Series

Kino.FS.file_path("226490.csv")
|> DataFrame.from_csv!()
|> DataFrame.mutate_with(fn lf ->
  %{
    "Price" => lf["Price"] |> Series.replace(",", "") |> Series.cast(:integer),
    "Open" => lf["Open"] |> Series.replace(",", "") |> Series.cast(:integer),
    "High" => lf["High"] |> Series.replace(",", "") |> Series.cast(:integer),
    "Low" => lf["Low"] |> Series.replace(",", "") |> Series.cast(:integer)
}
end)
|> DataFrame.arrange_with(& &1["Date"])

0.7.1 이후 버전에선 고쳐졌을 테니깐 Series.replace 를 사용해서 , 문자를 없애고 정수 타입으로 캐스팅하면 된다.

하지만 마냥 기다릴 순 없으니 지저분하게 해결했다.

df
|> DataFrame.put(:price, Series.transform(df["Price"], fn n ->
  n |> String.replace(",", "") |> String.to_integer()
end))
|> DataFrame.put(:open, Series.transform(df["Open"], fn n ->
  n |> String.replace(",", "") |> String.to_integer()
end))
|> DataFrame.put(:high, Series.transform(df["High"], fn n ->
  n |> String.replace(",", "") |> String.to_integer()
end))
|> DataFrame.put(:low, Series.transform(df["Low"], fn n ->
  n |> String.replace(",", "") |> String.to_integer()
end))

Explorer 함수가 아닌 외부 함수를 사용하려면 Series.transform/2 함수를 사용해야 한다. 그리고 mutate_with/2 함수로 수정할 수 없다. DataFrame.put/4 함수로 추가해야 한다.

Date Price price
2015-08-25 “18,510” 18510
2015-08-26 “18,925” 18925
2015-08-27 “19,110” 19110
2015-08-28 “19,395” 19395
2015-08-31 “19,365” 19365
2015-09-01 “19,170” 19170
2015-09-02 “19,160” 19160
2015-09-03 “19,175” 19175
2015-09-04 “18,850” 18850
2015-09-07 “18,840” 18840

문자열이 아닌 정수로 잘 변환이 됐다.

Volume 데이터 정제

3.81M, 359.48K 처럼 숫자가 크니깐 kilo, mega 단위를 썼다. float 타입으로 파싱할 수 있는 만큼만 하고 단위 문자를 숫자로 변환해서 곱해주면 된다.

df
|> DataFrame.put(
  :volume,
Series.transform(df["Vol."], fn
  "" ->
    nil

  n when is_binary(n) ->
    {vol, rest} = Float.parse(n)

  multiplier =
    case rest do
      "K" -> 1000
      "M" -> 1000 * 1000
    end

  floor(vol * multiplier)
end)
)

거래량이 비어있는 날도 있다. 거래가 1건도 없다고? 휴장인 걸로 추정한다. 뒤에서 다시 확인해 보자.

K, M 이외의 문자가 나오면 예외 처리를 하게 하지 않는다. 에러를 내게 한다. 어떤 문자인지 확인하고 그에 맞는 처리를 하기 위함이다. 단위 문자를 사용하려고 실수를 사용했다. 실제 수로 변환하면 충분히 큰 수라서 소수점 이하 숫자가 없기도 하고 의미도 없다. 그래서 계산 후 정수로 바꿔준다.

Vol. volume
359.48K 359480
256.93K 256930
304.18K 304180
549.82K 549820
297.94K 297940
247.38K 247380
255.38K 255380
258.26K 258260
326.93K 326930
315.61K 315610

백분율인 등락률을 비율로 변경

등락률이 -0.69% 처럼 백분율로 표시되어 있다. 최종 결과 가독성을 위해 백분율로 표시하는 건 좋다. 하지만 데이터를 가공할 때 백분율은 문자열로 저장이 되기에 Explorer 라이브러리가 제공하는 숫자 연산을 사용하지 못한다. 그래서 비율로 변경해 준다. 방법은 간단하다. % 문자를 * 0.01 로 바꿔서 계산하면 된다.

df
|> DataFrame.mutate_with(fn lf ->
  %{
    "Change %" =>
lf["Change %"]
|> Series.replace("%", "")
|> Series.cast(:float)
|> Series.multiply(0.01)
}
end)

Series.replace/3 함수를 DataFrame.mutate_with/2 함수 안에서 사용할 수 있는 패치가 적용된 0.7.1 다음 버전에서는 이렇게 계산할 수 있다. 지금은 안 되니깐 Volume 데이터를 정제한 것처럼 변경해서 새로운 column을 추가하는 방식으로 변환한다.

date Change % change
2015-08-25 1.12% 0.011200000000000002
2015-08-26 2.24% 0.022400000000000003
2015-08-27 0.98% 0.0098
2015-08-28 1.49% 0.0149
2015-08-31 -0.15% -0.0015
2015-09-01 -1.01% -0.0101
2015-09-02 -0.05% -0.0005
2015-09-03 0.08% 0.0008
2015-09-04 -1.69% -0.0169
2015-09-07 -0.05% -0.0005

주식 휴장일 걸러내기

주식장이 열리는 날에 수집한 데이터만 있는 것일까? 앞에서 Volume 데이터를 정제할 때, 이상한 데이터가 있는 걸 발견했다. 거래량이 기록되지 않은 날이 있다. 거래량이 기록되지 않은 날만 뽑아보자.

df =
  Kino.FS.file_path("226490.csv")
  |> DataFrame.from_csv!()
  |> DataFrame.filter_with(&Series.equal(&1["Vol."], ""))
  |> DataFrame.mutate_with(fn lf ->
  %{
    "Date" => lf["Date"] |> Series.strptime("%m/%d/%Y") |> Series.cast(:date)
}
end)
|> DataFrame.mutate_with(&[day_of_week: Series.day_of_week(&1["Date"])])

거래량이 비어 있는 row를 골라낸다. 요일도 표시하게 했다. 월요일이 1이고 일요일이 7이다.

Date Price Open High Low Vol. Change % day_of_week
2023-08-06 “26,515” “26,515” “26,515” “26,515”   0.00% 7
2023-04-23 “26,255” “26,255” “26,255” “26,255”   0.00% 7
2023-04-16 “26,530” “26,530” “26,530” “26,530”   0.00% 7
2023-04-09 “25,715” “25,715” “25,715” “25,715”   0.00% 7
2023-03-19 “24,720” “24,720” “24,720” “24,720”   0.00% 7
2022-05-08 “26,745” “26,745” “26,745” “26,745”   0.00% 7
2022-01-09 “30,095” “30,095” “30,095” “30,095”   0.00% 7
2021-12-19 “30,355” “30,355” “30,355” “30,355”   0.00% 7
2021-12-05 “29,860” “29,860” “29,860” “29,860”   0.00% 7
2020-12-13 “27,750” “27,750” “27,750” “27,750”   0.00% 7
2020-08-17 “24,110” “24,110” “24,110” “24,110”   0.00% 1
2020-08-02 “22,540” “22,540” “22,540” “22,540”   0.00% 7
2020-06-07 “21,860” “21,860” “21,860” “21,860”   0.00% 7
2020-05-17 “19,385” “19,385” “19,385” “19,385”   0.00% 7
2020-05-05 “19,060” “19,060” “19,060” “19,060”   0.00% 2
2020-05-03 “19,580” “19,580” “19,580” “19,580”   0.00% 7
2020-03-22 “16,055” “16,055” “16,055” “16,055”   0.00% 7
2020-03-15 “18,150” “18,150” “18,150” “18,150”   0.00% 7
2020-02-23 “22,110” “22,110” “22,110” “22,110”   0.00% 7
2018-10-09 “22,685” “22,685” “22,685” “22,685”   0.00% 2
2018-07-08 “22,860” “22,860” “22,860” “22,860”   0.00% 7

일요일과 공휴일이 포함된 걸 볼 수 있다. 왜 휴장 데이터가 포함될까? 우리나라 데이터만 수집하는 게 아닐 거다. 그러다 보니 이렇게 일요일과 공휴일 데이터도 수집된다. 약간 불친절한 것 같기도 하고. investing에서 안 걸러주니 우리가 걸러야 한다.

전체 소스코드

alias Explorer.DataFrame
alias Explorer.Series

df =
  Kino.FS.file_path("226490.csv")
  |> DataFrame.from_csv!()

df =
  df
  |> DataFrame.mutate_with(fn lf ->
    %{
      :date => lf["Date"] |> Series.strptime("%m/%d/%Y") |> Series.cast(:date)
    }
  end)
  |> DataFrame.put(
    :price,
    Series.transform(df["Price"], fn n ->
      n |> String.replace(",", "") |> String.to_integer()
    end)
  )
  |> DataFrame.put(
    :open,
    Series.transform(df["Open"], fn n ->
      n |> String.replace(",", "") |> String.to_integer()
    end)
  )
  |> DataFrame.put(
    :high,
    Series.transform(df["High"], fn n ->
      n |> String.replace(",", "") |> String.to_integer()
    end)
  )
  |> DataFrame.put(
    :low,
    Series.transform(df["Low"], fn n ->
      n |> String.replace(",", "") |> String.to_integer()
    end)
  )
  |> DataFrame.put(
    :volume,
    Series.transform(df["Vol."], fn
      "" ->
        nil

      n when is_binary(n) ->
        {vol, rest} = Float.parse(n)

        multiplier =
          case rest do
            "K" -> 1000
            "M" -> 1000 * 1000
          end

        floor(vol * multiplier)
    end)
  )
  |> DataFrame.put(
    :change,
    Series.transform(df["Change %"], fn s ->
      s
      |> String.replace("%", "")
      |> String.to_float()
      |> Kernel.*(0.01)
    end)
  )
  |> DataFrame.select([:date, :price, :open, :high, :low, :volume, :change])
  |> DataFrame.arrange(date)
  |> DataFrame.drop_nil()

마치며

백테스팅에 쓸 과거 데이터를 Investing.com에서 가져와 문자열을 date, float, integer 타입으로 변환하는 방법을 살펴봤다. 그리고 이렇게 과거 데이터를 쓸 때는 휴장일이 포함되어 있는지 확인해야 한다. price, open, high, low 컬럼 값이 다 같은지를 확인하거나 거래량이 비어있는 걸 확인해서 걸러내면 된다.