Pandas를 이용하여 틱데이터 가공하기

0. Introduction

체결 데이터(틱데이터)를 사용할 때, 데이터가 지나치게 raw해서 생기는 난감한 부분들이 많습니다. 이를 해결하기 위해 “분봉”이나 “일봉”처럼, 일정한 시간을 기준으로 체결내역을 grouping하는 전처리 방법이 가장 많이 사용되는 것 같습니다. 이러한 방법의 장단점은 무엇일까요? 혹시 다른 방법은 없을까요? 어떻게 구현을 해야 할까요?

Marcos Lopez de Prado의 저서 Advances in Financial Machine Learning에 위 질문들에 대한 답변이 정리가 잘 되어있습니다. 이번 포스팅은 해당 서적의 “2.3 Financial Data Structures: Bars” 챕터를 공부하며 이해한 내용을 정리한 내용입니다. 워낙 유명한 저서인지라, 책의 내용에 대한 구현을 정리해놓은 github repo도 있습니다. 다만, 모듈화를 위해 로직이 흩어져있어서 이해하기가 쉽지 않아, Python pandas를 사용하여 직접 구현을 해보았습니다.

포스팅의 내용 혹은 코드에 개선이 필요한 경우, 피드백을 주시면 감사하겠습니다. :)


시작하기에 앞서, 체결시각($t$)과 체결가격($p_t$, price), 체결량($v_t$, volume)으로 이루어진 틱 데이터가 필요합니다. 특별한 비용 없이도 증권사나 암호화폐 거래소 API를 사용하여 가져올 수 있습니다. 다음은 이베스트증권 API를 통해 받은 2020년 3월 25일 삼성전자(005930)의 틱데이터입니다. 추후 계산 편의를 위하여 거래대금($a_t$, value)을 $p_t\cdot v_t$로 미리 계산하였고, 인덱스로 사용된 체결시각을 오름차순으로 정렬했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> samsung = get_ticks('005930', '20200325')
>>> samsung['value'] = samsung['price'] * samsung['volume']
>>> samsung
t price volume value
2020-03-25 08:30:02 46950 10 469500
2020-03-25 08:30:05 46950 66 3098700
2020-03-25 08:30:10 46950 3 140850
2020-03-25 08:30:12 46950 87 4084650
2020-03-25 08:31:21 46950 43 2018850
... ... ... ...
2020-03-25 17:20:06 48800 20422 996593600
2020-03-25 17:30:28 48850 20486 1000741100
2020-03-25 17:40:28 48850 23020 1124527000
2020-03-25 17:50:12 49000 56124 2750076000
2020-03-25 18:00:15 49050 43268 2122295400
[226285 rows x 3 columns]

우리의 목표는 위 데이터를 이해하기 쉽고 사용하기 편한 (일종의) 테이블 형태로 변환하는 것입니다. 이러한 테이블의 행(row)을 “bar” 라고 부릅니다. 이번 포스팅에서는 bar를 추출하는 다양한 방법들을 소개합니다.


1. Standard Bars

1.1. Time bars

Definition

일정한 시간 간격으로 bar를 추출하는 방법으로, 흔히 접하는 “분봉”, “일봉” 등이 이에 속합니다. 시간 흐름에 따른 가격과 수급 현황을 확인할 수 있어서 가장 직관적인 데이터 형태이지만, 몇 가지 문제점들이 있습니다.

  1. 시장이 일정한 시간 단위로 정보를 처리하지 않습니다. 일정한 시간을 기준으로 데이터를 추출할 경우, 거래가 활발하지 않은 시기에는 데이터가 oversample 되고, 거래가 활발한 시기에는 undersample 되는 문제가 생깁니다.
  2. 일정한 시간으로 추출된 시계열 데이터의 통계 특성(i.e. serial correlation, heteroscedasticity, non-normality of returns)이 좋지 않은 경우가 많습니다.

이러한 문제들은 (앞으로 소개 될) 거래 내역을 기반으로 데이터를 추출하는 방법을 통해 해결할 수 있습니다.

Implementation

시계열 DataFrame(DataFrame with datetime-like index)에는 주어진 규칙에 따라 데이터를 정렬 할 수 있는 resample method가 있습니다. 이를 Resampler.ohlc와 같이 사용하면, 틱 데이터를 쉽게 timebar로 변환할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def timebar(tick, rule):
resample = tick.resample(rule)

bars = resample['price'].ohlc()

# 거래가 일어나지 않은 시간의 NaN 처리
bars['close'] = bars['close'].fillna(method='ffill')
bars['open'] = bars['open'].fillna(bars['close'])
bars['high'] = bars['high'].fillna(bars['close'])
bars['low'] = bars['low'].fillna(bars['close'])

bars[['volume', 'value']] = resample[['volume', 'value']].sum()

return bars
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
>>> timebar(samsung, '30s')
t open high low close volume value
2020-03-25 08:30:00 46950.0 46950.0 46950.0 46950.0 166 7793700
2020-03-25 08:30:30 46950.0 46950.0 46950.0 46950.0 0 0
2020-03-25 08:31:00 46950.0 46950.0 46950.0 46950.0 43 2018850
2020-03-25 08:31:30 46950.0 46950.0 46950.0 46950.0 31 1455450
2020-03-25 08:32:00 46950.0 46950.0 46950.0 46950.0 75 3521250
... ... ... ... ... ... ...
2020-03-25 17:58:00 49000.0 49000.0 49000.0 49000.0 0 0
2020-03-25 17:58:30 49000.0 49000.0 49000.0 49000.0 0 0
2020-03-25 17:59:00 49000.0 49000.0 49000.0 49000.0 0 0
2020-03-25 17:59:30 49000.0 49000.0 49000.0 49000.0 0 0
2020-03-25 18:00:00 49050.0 49050.0 49050.0 49050.0 43268 2122295400

[1141 rows x 6 columns]

>>> timebar(samsung, '1H')
t open high low close volume value
2020-03-25 08:00:00 46950 46950 46950 46950 908 42630600
2020-03-25 09:00:00 48900 49600 47550 48050 19104046 930657231400
2020-03-25 10:00:00 48050 48700 47900 48350 6251831 302241758300
2020-03-25 11:00:00 48350 48450 47800 48300 4902528 235849172200
2020-03-25 12:00:00 48250 48450 48000 48150 2985362 143843200950
2020-03-25 13:00:00 48100 48200 47150 48100 6703650 320201734850
2020-03-25 14:00:00 48100 48900 47550 48800 7213952 347588903150
2020-03-25 15:00:00 48850 48900 48450 48650 5089598 247668090050
2020-03-25 16:00:00 48600 48700 48550 48700 88125 4285782150
2020-03-25 17:00:00 48800 49000 48800 49000 172326 8422908900
2020-03-25 18:00:00 49050 49050 49050 49050 43268 2122295400

1.2. Tick bars

Definition

일정한 수의 체결이 발생하였을 때마다 bar를 추출하는 방법입니다. 체결이 많이 일어나는 시점에는 더 많은 bar가 추출되기 때문에, 시장으로 들어오는 정보의 속도를 time bar보다 잘 반영합니다. 또한, tick bar로 계산되는 수익률($r_i$)이 iid 정규분포에 더 가깝다는 연구도 있습니다. (Ane & Geman, 2000)

하지만, 다음과 같은 점들이 문제가 될 수 있습니다.

  1. 다수의 주문들이 한번에 체결되는 경우 outlier가 발생할 수 있습니다. 예를 들어, 장 마감 전 동시호가 시간(15:20~15:30)에는 주문이 체결되지 않고 누적되다가, 15:30분에 주문들이 한번에 체결되며 하나의 틱으로 처리가 됩니다. 하나의 틱이 수많은 체결을 포함하고 있는 셈입니다.
  2. 반대로, 하나의 주문이 여러번에 걸쳐 체결이 되는 경우도 문제가 됩니다. (a.k.a. “order fragmentation”) 정상적인 주문에 의한 다수의 체결인 경우에는 상관 없을 수 있지만, 트레이딩 봇에 의한 경우나 (운용/개발 편의를 위해) 체결 엔진에 의한 경우에는 데이터에 왜곡이 생길 우려가 있습니다.

Implementation

Tick bar는 time bar와 비슷한 방법으로 구현이 가능합니다. 이전과는 다르게, 체결 시각을 기준으로 데이터를 resampling 하는 것이 아니므로, 새로운 인덱스(window_number)를 추가한 뒤 groupby() method를 통해 데이터를 묶어주었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def tickbar(tick, window_size):
tick = tick.reset_index()

tick['window_number'] = np.arange(len(tick)) // window_size
groupby = tick.groupby('window_number')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> tickbar(samsung, 10)
t open high low close volume value
2020-03-25 08:30:02 46950 46950 46950 46950 315 14789250
2020-03-25 08:32:37 46950 46950 46950 46950 338 15869100
2020-03-25 08:35:51 46950 46950 46950 46950 255 11972250
2020-03-25 09:00:26 48900 48950 48900 48900 3150 154048850
2020-03-25 09:00:26 48900 48950 48900 48950 9649 471903050
... ... ... ... ... ... ...
2020-03-25 15:59:11 48650 48650 48650 48650 630 30649500
2020-03-25 15:59:26 48650 48650 48650 48650 2275 110678750
2020-03-25 15:59:40 48650 48650 48600 48600 15808 768327150
2020-03-25 16:20:23 48600 49000 48550 49000 245810 11997138450
2020-03-25 18:00:15 49050 49050 49050 49050 43268 2122295400

[22709 rows x 6 columns]

1.3. Volume bars

Definition

일정한 수준의 거래량이 발생할 때마다 bar를 추출하는 방법입니다. 거래량을 기준으로 데이터를 추출하기 때문에, order fragmentation에 의해 데이터가 왜곡되는 tick bar의 문제점을 어느정도 해결할 수 있습니다. Tick bar로 계산되는 수익률보다 volume bar로 계산되는 수익률이 iid 정규분포에 더 가깝다는 연구도 있습니다. (Clark, 1973) 이뿐만 아니라, 가격과 거래량의 상호작용에 대한 시장 미시구조 이론들을 적용하기에도 용이합니다.

다만, “거래량 하나”의 가치가 시장의 여러 요인들에 의해 지속적으로 변한다는 점을 감안해야 합니다. 따라서 가격 변동이 큰 데이터(ex. 암호화폐)를 사용하여 volume bar를 추출 할 경우, 각 bar의 “가치” 차이가 클 수 있습니다. 가격의 변동 뿐만 아니라, 유통 주식수에 영향을 주는 이벤트(주식발행, 액면 병합/분할, 자사주 매입 등)도 단위 거래량의 가치를 변동시킬 수 있다는 점을 조심해야 합니다.

(Naive) Implementation

모든 bar가 (정확하게) 동일한 단위 거래량을 가지도록 volume bar를 추출하기 위해서는 iteration이 필요해 보입니다. (누적 거래량이 단위 거래량을 넘어가는 순간, 하나의 틱을 여러개로 나눠야 하는 로직이 필요할 것 같습니다.) 이 경우, numpy의 효율적인 퍼포먼스를 충분히 활용하기가 어렵습니다.

Tick bar를 계산하는 방법을 응용하면, volume bar를 approximate 할 수 있는 bar를 아래와 같이 계산할 수 있습니다. 단위 거래량이 커질수록, 아래의 방법으로 계산 된 bar는 volume bar와 유사해집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def naive_volumebar(tick, unit_volume):
tick = tick.reset_index()

tick['window_number'] = tick['volume'].cumsum() // unit_volume
groupby = tick.groupby('window_number')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars
1
2
3
4
5
6
7
8
9
10
11
12
>>> naive_volumebar(samsung, 500_000).head(10)
t open high low close volume value
2020-03-25 08:30:02+09:00 46950 46950 46950 46950 908 42630600
2020-03-25 09:00:26+09:00 48950 48950 48500 48750 2999031 146769006600
2020-03-25 09:00:36+09:00 48800 48850 48350 48500 499415 24269779250
2020-03-25 09:01:07+09:00 48450 48800 48450 48750 500462 24323620400
2020-03-25 09:01:47+09:00 48750 49000 48700 49000 480488 23495366100
2020-03-25 09:02:32+09:00 49000 49250 49000 49250 517832 25422146200
2020-03-25 09:03:08+09:00 49250 49500 49200 49500 501844 24769576200
2020-03-25 09:03:55+09:00 49500 49600 49450 49500 498855 24702622450
2020-03-25 09:04:26+09:00 49500 49600 49250 49400 501011 24745938700
2020-03-25 09:05:46+09:00 49400 49500 49300 49350 500149 24708946850

거래가 상대적으로 적게 일어나는 종가매매 시간(08:30)과 시가단일가 주문이 한번에 체결되는 장 개시 시점(09:00)을 제외하면, 거래량이 50만주에 가깝게 bar들이 추출되었습니다. 참고로 500_000에서 underscore(_)는 가독성을 위하여 자릿수를 나누어 주는 역할을 하고, 실제 값은 500000과 동일합니다. (PEP-515)

1.4. Dollar bars

Definition

일정한 수준의 거래대금이 누적 될 때마다 bar를 추출하는 방법입니다. 실제 거래되는 현금의 양을 기준으로 데이터를 처리하기 때문에, volume bar에 비해 가격변동, 주식발행, 액면 병합/분할, 자사주 매입 등의 이벤트에 대해 robust하고, 단위 거래량의 가치 변화에 조금 더 consistent 합니다.

위 그림은 E-mini S&P 500 선물 틱 데이터를 tick bar, volume bar, dollar bar로 계산한 뒤, 하루에 발생하는 bar의 개수를 나타낸 그래프입니다.(Lopez de Prado, 2018) 다른 데이터에 비해서 dollar bar가 더 consistent하게 추출되는 경향을 볼 수 있습니다. 더 나아가서, 각 bar를 추출하는 단위 거래대금의 크기를 유동적으로 조절하여(ex. 유동시가총액에 비례하는 단위), 보다 더 consistent한 데이터를 얻을 수 있겠습니다.

(Naive) Implementation

거래량 또는 거래대금을 기준으로 bar를 추출한다는 점만 제외하면, dollar bar는 volume bar와 같은 로직을 가지고 있습니다. Volume bar의 구현을 참고하여 다음과 같이 구현할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def naive_dollarbar(tick, unit_value):
tick = tick.reset_index()

tick['window_number'] = tick['value'].cumsum() // unit_value
groupby = tick.groupby('window_number')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars
1
2
3
4
5
6
7
8
9
10
11
12
>>> naive_dollarbar(samsung, 50_000_000_000).head(10)
t open high low close volume value
2020-03-25 08:30:02+09:00 46950 46950 46950 46950 908 42630600
2020-03-25 09:00:26+09:00 48950 48950 48500 48800 3058052 149640289350
2020-03-25 09:00:40+09:00 48500 48850 48350 48850 1025849 49868417850
2020-03-25 09:01:58+09:00 48850 49350 48800 49300 1028290 50435367200
2020-03-25 09:03:21+09:00 49300 49600 49250 49300 1010431 49977830750
2020-03-25 09:04:58+09:00 49300 49500 49250 49350 972074 48001468250
2020-03-25 09:06:53+09:00 49300 49350 48750 48800 1060961 52033926150
2020-03-25 09:08:27+09:00 48800 49150 48650 49100 1023033 49984091500
2020-03-25 09:11:17+09:00 49100 49250 48800 48850 1020253 50014684300
2020-03-25 09:14:15+09:00 48850 48850 48350 48400 1028829 49976391950

Volume bar와 비슷한 이유로, 장 시작 직전과 직후를 제외하면, 각 bar의 거래대금이 50BnKRW에 가깝게 데이터가 처리되었습니다.


2. Information-Driven Bars

2.0. The tick rule

Definition

Information-driven bar는 시장에 새로운 정보가 유입되었을 때, 더 많은 bar를 추출하는 접근방법입니다. 시장에 새로운 정보가 유입이 되었다는 사실은 어떻게 알 수 있을까요?

시장미시구조론에서는 다음과 같은 흐름으로 논리가 전개됩니다. 시장에 새로운 정보가 유입이 되었을 경우, 해당 정보를 참고하여 시장에 참여하는 “informed trader”들이 생깁니다. 이로 인해 가격은 새로운 균형점으로 이동하려고 하고, 그 과정에서 매도/매수 세력이 불균형해집니다. 역으로 생각해보면, 이러한 불균형이 관찰이 된다면 시장에 새로운 정보가 반영되었다고 유추를 할 수 있습니다. 즉, 시장 외부 이벤트들을 (NLP 등을 이용하여) 직접 포착하는 것이 아니라, 이벤트가 시장에 녹아들 때 발생하는 시장의 현상을 포착하는 것입니다.

이를 위해서는, “매도/매수 세력의 불균형”을 수치화하여 활용해야 합니다. 한 가지 방법으로, 틱 데이터에 tick rule을 적용하여 signed tick ($b_t \in {-1, 1}$) 을 정의할 수 있습니다.

\begin{equation*} b_t= \begin{cases} b_{t-1} & \text{if } \Delta p_t=0 \\ \frac{\left|\Delta p_t\right|}{\Delta p_t} & \text{if } \Delta p_t\neq0 \end{cases} \quad \text{or}\ \ b_t= \begin{cases} 1 & \text{if } \Delta p_t > 0 \\ -1 & \text{if } \Delta p_t < 0 \\ b_{t-1} & \text{if } \Delta p_t=0 \end{cases} \end{equation*}

복잡해 보이는 식이지만, 의미는 간단합니다. 거래가 직전 거래보다 높은 가격에 체결됐을 경우 1, 낮은 가격에 체결됐을 경우 -1, 같은 가격에 체결됐을 경우 $b_{t-1}$ 값을 그대로 사용하는 방법입니다.

Signed tick은 trade’s agressor side를 수치화하기 위한 알고리즘입니다. 거래가 체결되는 형태는 크게 “지정가 매도주문 + 시장가 매수주문”이 매칭이 되는 경우와 “시장가 매도주문 + 지정가 매수주문”이 매칭이 되는 경우가 있습니다. 매도호가가 매수호가보다 크기 때문에, 전자의 체결가가 더 높습니다. 즉, 직전 거래와 비교하였을 때 체결가가 높다면, 이번 거래는 시장가 매수주문에 의해 체결되었을 가능성이 높은 셈입니다. (아래 Pitfall 항목에서 언급하겠지만, 항상 그렇지는 않습니다.) 이를 반영하여 signed tick을 다르게 설명하면, 시장가 매수주문에 의해 거래가 발생한 경우 $b_t=1$, 시장가 매도주문에 의해 거래가 거래가 발생한 경우에는 $b_t=-1$으로 정의하는 방법이라고 할 수 있습니다.

자산(혹은 계약)의 가격이 상승압력을 받게 되면 시장가 매수주문이 많이 발생하게 되고, $b_t=1$인 경우가 상대적으로 많이 관찰이 될 것입니다. 따라서, $b_t=1$인 경우가 어느 수준 이상 관찰될 때마다 bar를 추출하면, 시장에 유입되는 정보를 기준으로 bar를 추출할 수 있습니다. 반대의 경우($b_t=-1$)도 마찬가지 입니다.

Pitfall

호가가 변하는 경우, agressor side를 제대로 파악하지 못하는 문제가 생깁니다. 아래의 예시를 보겠습니다.

  1. 매도호가 11,000원, 매수호가 10,000원인 주식, 이전 체결가 10,000원 ($p_0=10000$)
  2. $t=1$ 시점에서 시장가 매수주문 접수, 체결 ($p_1=11000$, $b_1=1$)
  3. 지정가 매도주문 10,900원으로 접수. 현재 매도호가 10,900원, 매수호가 10,000원
  4. $t=2$ 시점에서 시장가 매수주문 접수, 체결 ($p_2=10900$, $b_2=-1 \because \Delta p_2<0$)

시장가 매수주문이 체결되었음에도, $b_2=-1$이 되는 문제가 발생합니다. 매수주문과 매도주문이 균형있게 체결되는 시장에서 위의 문제는 시간이 지나면서 해소가 될 수 있습니다. 하지만 유동성이 부족하거나 변동이 큰 종목의 경우에는 문제가 될 소지가 있으므로, 이에 대해 인지를 하고 있어야 합니다.

사용하는 API에 따라, signed tick 정보를 제공하는 경우도 있습니다. 예를 들어 코인원 API에서 제공하는 최근 체결 내역의 경우, 체결 시각과 체결가, 체결량과 함께 agressor side를 명시한 is_ask라는 데이터를 함께 제공합니다. 이 경우, 해당 데이터를 signed tick으로 활용하는 방법도 고려해볼 수 있겠습니다.

Implementation

1
2
3
def signed_tick(tick, initial_value=1.0):
diff = tick['price'] - tick['price'].shift(1)
return (abs(diff) / diff).ffill().fillna(initial_value)

Signed tick을 계산하기 위해서 필요한 직전 거래의 체결가를 구하기 위해, pandas의 shift() method를 사용합니다. 체결가에 변동이 없는 경우, diff가 0이 되어 abs(diff)/diff 값이 np.nan으로 계산되도록 한 뒤, ffill() (forward fill)을 통해 이전 값을 사용하도록 합니다. 틱 데이터의 앞 부분에서는 signed_tick이 정의될 수 없으므로, 임의의 값 (initial_value=1.0)을 넣어줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> samsung['signed_tick'] = signed_tick(samsung)
>>> samsung.iloc[50:60]
t price volume value signed_tick
2020-03-25 09:00:26+09:00 48900 10 489000 -1.0
2020-03-25 09:00:26+09:00 48900 20 978000 -1.0
2020-03-25 09:00:26+09:00 48900 50 2445000 -1.0
2020-03-25 09:00:26+09:00 48950 69 3377550 1.0
2020-03-25 09:00:26+09:00 48900 2250 110025000 -1.0
2020-03-25 09:00:26+09:00 48900 148 7237200 -1.0
2020-03-25 09:00:26+09:00 48950 5 244750 1.0
2020-03-25 09:00:26+09:00 48950 3 146850 1.0
2020-03-25 09:00:26+09:00 48950 10 489500 1.0
2020-03-25 09:00:26+09:00 48950 300 14685000 1.0

2.1. Tick imbalance bars (TIBs)

Definition

Signed tick이 편향되었다는 판단은 다양한 방법으로 내릴 수 있습니다. 그 중 한 방법으로, signed tick을 누적하여 tick imbalance ($\theta_T$) 를 정의할 수 있습니다. $T$ 시점에서의 tick imbalance는 다음과 같이 정의됩니다.

\begin{align*} \theta_T = \sum^{T}_{t=1}{b_t} \end{align*}

그 다음으로, 현 시점에서 예상되는 다음 tick imbalance bar의 tick imbalance 값, $E_0[\theta_T]$를 구해줍니다.

\begin{align*} E_0[\theta_T] = E_0[T](P[b_t=1] - P[b_t=-1]) = E_0[T] \cdot E_0[b_t] \end{align*}

여기서 $E_0[T]$는 현 시점에서 예상되는 다음 bar의 크기 (틱의 개수), $E_0[b_t]$은 $b_t$의 기대값입니다. $E_0[T]$와 $E_0[b_t]$ 값을 구하는 방법 중 하나는, 이전에 추출된 tick imbalance bar들의 $T$값과 $b_t$값에 exponentially weighted moving average를 적용하여 사용하는 방법이 있습니다.

마지막으로, tick imbalance의 크기가 예상 tick imbalance를 넘어가는 순간에 bar를 정의합니다.

\begin{align*} T^* = \underset{T}{\operatorname{argmin}}\left\{ |\theta_T|\ge \left| E_0[\theta_T] \right| \right\} \end{align*}

Bar를 정의하는데 사용된 데이터($t \in [1,T^*]$)를 제외하고 위의 과정을 반복해주면, 전체 데이터에 대한 tick imbalance bar를 정의할 수 있습니다.

틱 데이터가 예상한 것 이상으로 불균형 할 때 $\theta_T$ 값은 커지고, 더 작은 $T$ 값으로도 $T^*$를 정의할 수 있습니다. 다르게 이야기하면, 정보의 비대칭으로 인하여 방향성이 있는 informed trading이 시장에 많이 존재 할 수록, TIB는 더 많이 생성됩니다. 따라서, TIB는 일종의 “동일한 양의 정보를 포함하는 데이터들의 묶음” 으로 생각할 수도 있겠습니다.

Implementation

이전의 구현들은 numpy의 퍼포먼스를 끌어올리기 위해서 최대한 vectorized 연산을 활용하였습니다. TIB의 경우, bar를 나누는 기준이 되는 $E_0[\theta_T]$의 값이 bar를 추출하는 과정 중에 지속적으로 업데이트 됩니다. Iterative한 알고리즘으로 구현을 해야 할 것으로 보입니다.

아래의 코드는, 데이터 iterate를 하는 동안 연산의 횟수를 줄이는 데 초점을 맞춘 구현입니다. pandas DataFrame의 loc, iloc은 역할이 다양하여 약간의 overhead가 있습니다. (Pandas doc) 따라서, dataframe 외부에서 tick numbering을 해준 뒤, 한번에 column을 새로 만들어주는 방법을 사용했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def tick_imbalance_bar(
tick,
initial_expected_bar_size=10,
initial_expected_signed_tick=.1,
lambda_bar_size=.1,
lambda_signed_tick=.1,
):
tick = tick.sort_index(ascending=True)
tick = tick.reset_index()

# Part 1. Tick imbalance 값을 기반으로, bar numbering(`tick_imbalance_group`)
tick_imbalance = signed_tick(tick).cumsum().values
tick_imbalance_group = []

expected_bar_size = initial_expected_bar_size
expected_signed_tick = initial_expected_signed_tick
expected_tick_imbalance = expected_bar_size * expected_signed_tick

current_group = 1
previous_i = 0

for i in range(len(tick)):
tick_imbalance_group.append(current_group)

if abs(tick_imbalance[i]) >= abs(expected_tick_imbalance):
expected_bar_size = (
lambda_bar_size * (i-previous_i+1) +
(1-lambda_bar_size) * expected_bar_size
)
expected_signed_tick = (
lambda_signed_tick * tick_imbalance[i] / (i-previous_i+1) +
(1-lambda_signed_tick) * expected_signed_tick
)
expected_tick_imbalance = expected_bar_size * expected_signed_tick

tick_imbalance -= tick_imbalance[i]

previous_i = i
current_group += 1

# Part 2. Bar numbering 기반으로, OHLCV bar 생성
tick['tick_imbalance_group'] = tick_imbalance_group
groupby = tick.groupby('tick_imbalance_group')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars

2.2. Volume/Dollar imbalance bars (VIBs/DIBs)

Definition

Tick bars의 문제점을 해결하기 위하여 volume bar와 dollar bar로 개념을 확장한 것과 같이, tick imbalance bar의 개념을 volume imbalance bar와 dollar imbalance bar로 확장할 수 있습니다. 거래량 혹은 거래대금의 비대칭이 예상 수준을 넘어설 때 bar를 추출하는 방법입니다. 그 과정은 TIB와 동일합니다.

참고한 서적에서는 $b_t=1$인 경우와 $b_t=-1$인 경우를 따로 구분하여 계산하지만, 저는 약간 다른 방식으로 내용을 이해했습니다. 가장 먼저, 앞서서 정의했던 signed tick을 활용하여 signed volume 혹은 signed value ($c_t$) 를 정의합니다. 여기서 $v_t$는 추출하려는 bar의 종류에 따라 거래량(VIB) 혹은 거래대금(DIB) 값을 사용합니다.

\begin{align*} c_t = b_t v_t \end{align*}

다음으로, $T$ 시점에서의 imbalance, $\theta_T$를 아래와 같이 정의합니다.

\begin{align*} \theta_T = \sum^{T}_{t=1}{c_t} = \sum^{T}_{t=1}{b_t v_t} \end{align*}

그 다음으로, 현 시점에서 예상되는 다음 bar의 imbalance 값, $E_0[\theta_T]$를 구해줍니다.

\begin{align*} E_0[\theta_T] &= E_0[T](P[b_t=1]E_0[c_t|b_t=1] + P[b_t=-1]E_0[c_t|b_t=-1]) \\ &= E_0[T]\cdot E_0[c_t] \end{align*}

Tick imbalance bar를 계산할 때와 마찬가지로, $E_0[T]$ 값과 $E_0[c_t]$ 값은 이전 bar들의 $T$값과 $c_t$ 값에 exponential weighted moving average를 취해 근사할 수 있습니다.

마지막으로, imbalance의 크기가 예상 수치($E_0[\theta_T]$)를 넘어가는 순간에 bar를 추출합니다.

\begin{align*} T^* = \underset{T}{\operatorname{argmin}}\left\{ |\theta_T|\ge \left| E_0[\theta_T] \right| \right\} \end{align*}

Bar를 정의하는데 사용된 데이터($t \in [1,T^*]$)를 제외하고 위의 과정을 반복해주면, 전체 데이터에 대한 volume imbalance bar (혹은 dollar imbalance bar)를 정의할 수 있습니다.

Implementation

Imbalance를 정의한 뒤, 해당 imbalance가 예상 수치를 벗어나는 경우 bar를 추출한다는 점에서 TIB와 VIB, DIB의 로직은 동일합니다. 따라서, TIB의 구현에서 imbalance의 값을 조절하여 DIB와 VIB를 추출할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def imbalance_bar(
type, # 'tick', 'volume', 'dollar'
tick,
initial_expected_bar_size,
initial_expected_signed,
lambda_bar_size=.1,
lambda_signed=.1,
):
tick = tick.sort_index(ascending=True)
tick = tick.reset_index()

# Part 1. Imbalance 값을 기반으로, bar numbering(`imbalance_group`)
if type == 'tick':
imbalance = signed_tick(tick).cumsum().values
elif type == 'volume':
imbalance = (signed_tick(tick) * tick['volume']).cumsum().values
elif type == 'dollar':
imbalance = (signed_tick(tick) * tick['value']).cumsum().values
else:
raise ValueError('`type`은 ["tick", "volume", "dollar"] 중 한 가지 값을 가져야 합니다.')
imbalance_group = []

expected_bar_size = initial_expected_bar_size
expected_signed = initial_expected_signed
expected_imbalance = expected_bar_size * expected_signed

current_group = 1
previous_i = 0

for i in range(len(tick)):
imbalance_group.append(current_group)

if abs(imbalance[i]) >= abs(expected_imbalance):
expected_bar_size = (
lambda_bar_size * (i-previous_i+1) +
(1-lambda_bar_size) * expected_bar_size
)
expected_signed = (
lambda_signed * imbalance[i] / (i-previous_i+1) +
(1-lambda_signed) * expected_signed
)
expected_imbalance = expected_bar_size * expected_signed

previous_i = i
imbalance -= imbalance[i]
current_group += 1

# Part 2. Bar numbering 기반으로, OHLCV bar 생성
tick['imbalance_group'] = imbalance_group
groupby = tick.groupby('imbalance_group')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars

2.3. Tick runs bars (TRBs)

Definition

앞서 살펴본 tick imbalance bar는 signed tick의 누적값을 기준으로 bar를 추출합니다. Tick runs bar는 signed tick을 하나의 값으로 누적하는 것에서 더 나아가, $b_t=1$인 경우와 $b_t=-1$인 경우를 따로 누적하여, 두 수치 사이의 비대칭을 기준으로 bar를 추출하는 방법입니다. 하나의 큰 거래가 호가를 밀면서 체결되거나 여러개의 작은 거래로 나뉘어져서 체결되는 경우 $b_{t=1,…,T}$에 run(연속된 데이터 흔적)을 남기게 되는데, 이를 포착하는 방법입니다.

가장 먼저, 현재 run의 길이, $\theta_T$를 다음과 같이 정의합니다.

\begin{align*} \theta_T = \text{max}\left\{\sum^{T}_{t|b_t=1}{b_t}, -\sum^{T}_{t|b_t=-1}{b_t} \right\} \end{align*}

통계학에서 말하는 run은 동일한 관측값이 (끊김없이) 연속적으로 이어진 것을 의미합니다. (Wikipedia) 이와는 약간 다르게, 서적에서 정의한 run은 데이터의 끊김(sequence break)을 허용합니다. 즉, 여기서 말하는 “run의 길이”는 연속된 데이터의 실질적인 길이를 의미하는 것이 아니라, 일정 기간 동안 관찰된 최빈값의 빈도수를 의미합니다.

다음으로, 현 시점에서 예상되는 다음 run의 길이, $E[\theta_T]$를 구해줍니다.

\begin{align*} E_0[\theta_T] &= E_0[T]\cdot \text{max}\left\{ P[b_t=1], P[b_t=-1] \right\} \\ &= E_0[T]\cdot \text{max}\left\{ P[b_t=1], 1-P[b_t=1] \right\} \end{align*}

Imbalance bar와 마찬가지로, $E_0[T]$와 $P[b_t=1]$ 값은 추정치를 사용하고, 이전 bar의 $T$, $P[b_t=1]$(proportion of buy ticks)의 EWMA 값을 사용하는 것이 하나의 방법입니다.

이후, run의 길이가 예상되는 run의 길이를 넘어가는 순간 bar를 추출합니다.

\begin{align*} T^* = \underset{T}{\operatorname{argmin}}\left\{ \theta_T \ge E_0[\theta_T] \right\} \end{align*}

Bar를 정의하는데 사용된 데이터($t \in [1,T^*]$)를 제외하고 위의 과정을 반복해주면, 전체 데이터에 대한 tick run bar를 정의할 수 있습니다.

Implementation

로직은 TIB, VIB, DIB의 구현과 동일하고, bar를 추출하는 기준(라인 33)만 다릅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def tick_runs_bar(
tick,
initial_expected_bar_size,
initial_buy_prob,
lambda_bar_size=.1,
lambda_buy_prob=.1,
):
tick = tick.sort_index(ascending=True)
tick = tick.reset_index()

_signed_tick = signed_tick(tick)
imbalance_tick_buy = _signed_tick.apply(lambda v: v if v>0 else 0).cumsum()
imbalance_tick_sell = _signed_tick.apply(lambda v: -v if v<0 else 0).cumsum()

group = []

expected_bar_size = initial_expected_bar_size
buy_prob = initial_buy_prob
expected_runs = expected_bar_size * max(buy_prob, 1-buy_prob)

current_group = 1
previous_i = 0
for i in range(len(tick)):
group.append(current_group)

if max(imbalance_tick_buy[i], imbalance_tick_sell[i]) >= expected_runs:
expected_bar_size = (
lambda_bar_size * (i-previous_i+1) +
(1-lambda_bar_size) * expected_bar_size
)

buy_prob = (
lambda_buy_prob * imbalance_tick_buy[i]/(i-previous_i+1) +
(1-lambda_buy_prob) * buy_prob
)

previous_i = i
imbalance_tick_buy -= imbalance_tick_buy[i]
imbalance_tick_sell -= imbalance_tick_sell[i]
current_group += 1

tick['group'] = group
groupby = tick.groupby('group')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars

2.4 Volume/Dollar runs bars (VRBs/DRBs)

Definition

Tick runs bars의 개념을 확장하여, 거래량 혹은 거래대금으로 runs bars를 추출하는 방법입니다. 먼저, 거래량/거래대금 run($\theta_T$)를 다음과 같이 정의합니다. 위와 마찬가지로, $v_t$는 추출하려는 bar의 종류에 따라 거래량(VRB) 혹은 거래대금(DRB) 값을 사용합니다.

\begin{align*} \theta_T = \operatorname{max}\left\{ \sum^{T}_{t|b_t=1}{b_tv_t}, -\sum^{T}_{t|b_t=-1}{b_tv_t} \right\} \end{align*}

다음으로, 현 시점에서 예상되는 다음 bar의 run 값, $E_0[\theta_T]$를 계산합니다.

\begin{align*} E_0[\theta_t] = E_0[T]\operatorname{max}\left\{ P[b_t=1]E_0[v_t|b_t=1], (1-P[b_t=1])E_0[v_t|b_t=-1] \right\} \end{align*}

위 수식에서 필요한 몇 가지 값들은 아래와 같습니다. (TIB, VIB, DIB, TRB에 비해 estimate 해야 하는 값들이 더 많습니다.)

  • $E_0[T]$는 (이전 bar들의) $T$ 값의 EWMA를 사용합니다.
  • $P[b_t=1]$은 (이전 bar들의) $P[b_t=1]$(proportion of buy ticks)값들의 EWMA를 사용합니다.
  • $E_0[v_t|b_t=1]$은 (이전 bar들의) 시장가 매도수량값들의 EWMA를 사용합니다.
  • $E_0[v_t|b_t=-1]$은 (이전 bar들의) 시장가 매수수량값들의 EWMA를 사용합니다.

마지막으로, run의 길이가 예상되는 run의 길이를 넘어가는 순간 bar를 추출합니다.

\begin{align*} T^* = \underset{T}{\operatorname{argmin}}\left\{ \theta_T \ge E_0[\theta_T] \right\} \end{align*}

Bar를 정의하는데 사용된 데이터($t \in [1,T^*]$)를 제외하고 위의 과정을 반복해주면, 전체 데이터에 대한 volume/dollar run bar를 정의할 수 있습니다.

Implementation

TIB를 VIB/DIB로 구현을 확장한 것과 같이, TRB의 구현을 응용하여 VRB/DRB를 아래와 같이 추출할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def volume_runs_bar(
tick,
initial_expected_bar_size,
initial_buy_prob,
initial_buy_volume,
initial_sell_volume,
lambda_bar_size=.1,
lambda_buy_prob=.1,
lambda_buy_volume=.1,
lambda_sell_volume=.1
):
tick = tick.sort_index(ascending=True)
tick = tick.reset_index()

_signed_tick = signed_tick(tick)
_signed_volume = _signed_tick * tick['volume']
imbalance_tick_buy = _signed_tick.apply(lambda v: v if v>0 else 0).cumsum()
imbalance_volume_buy = _signed_volume.apply(lambda v: v if v>0 else 0).cumsum()
imbalance_volume_sell = _signed_volume.apply(lambda v: v if -v<0 else 0).cumsum()

group = []

expected_bar_size = initial_expected_bar_size
buy_prob = initial_buy_prob
buy_volume = initial_buy_volume
sell_volume = initial_sell_volume
expected_runs = expected_bar_size * max(buy_prob * buy_volume, (1-buy_prob) * sell_volume)

current_group = 1
previous_i = 0
for i in range(len(tick)):
group.append(current_group)

if max(imbalance_volume_buy[i], imbalance_volume_sell[i]) >= expected_runs:
expected_bar_size = (
lambda_bar_size * (i-previous_i+1) +
(1-lambda_bar_size) * expected_bar_size
)

buy_prob = (
lambda_buy_prob * imbalance_tick_buy[i]/(i-previous_i+1) +
(1-lambda_buy_prob) * buy_prob
)

buy_volume = (
lambda_buy_volume * imbalance_volume_buy[i] +
(1-lambda_buy_volume) * buy_volume
)

sell_volume = (
lambda_sell_volume * imbalance_volume_sell[i] +
(1-lambda_sell_volume) * sell_volume
)

previous_i = i
imbalance_tick_buy -= imbalance_tick_buy[i]
imbalance_volume_buy -= imbalance_volume_buy[i]
imbalance_volume_sell -= imbalance_volume_sell[i]
current_group += 1

tick['group'] = group
groupby = tick.groupby('group')

bars = groupby['price'].ohlc()
bars[['volume', 'value']] = groupby[['volume', 'value']].sum()
bars['t'] = groupby['t'].first()

bars.set_index('t', inplace=True)

return bars

3. Appendix

3.1. Timebar → Timebar

간혹, 하나의 timebar를 다른 단위의 timebar로 변환을 해야 할 때가 있습니다. 이 경우, pandas.DataFrame.resample을 통해 시계열 index를 재정렬 할 수 있습니다.

아래의 timebar_1min 데이터는 2021년 2월 11일 00:00부터 15일 08:40까지의 비트코인 1분봉 데이터입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>>> timebar_1min
open high ... volume value
t ...
2021-02-11 00:00:00 48384000.0 48391000.0 ... 20.124271 9.732357e+08
2021-02-11 00:01:00 48377000.0 48587000.0 ... 16.129580 7.815445e+08
2021-02-11 00:02:00 48446000.0 48446000.0 ... 14.955761 7.228841e+08
... ... ... ... ...
2021-02-15 08:38:00 52470000.0 52620000.0 ... 10.435710 5.480798e+08
2021-02-15 08:39:00 52592000.0 52626000.0 ... 2.021345 1.063235e+08
2021-02-15 08:40:00 52625000.0 52627000.0 ... 1.383658 7.281528e+07
[6281 rows x 6 columns]

>>> timebar_1min.index
DatetimeIndex(['2021-02-11 00:00:00', '2021-02-11 00:01:00',
'2021-02-11 00:02:00', '2021-02-11 00:03:00',
...
'2021-02-15 08:37:00', '2021-02-15 08:38:00',
'2021-02-15 08:39:00', '2021-02-15 08:40:00'],
dtype='datetime64[ns]', name='t', length=6281, freq='T')

>>> timebar_1min.columns
Index(['open', 'high', 'low', 'close', 'volume', 'value'], dtype='object')

1분봉 데이터를 60분봉 데이터로 변환하려면, 다음과 같이 resampling을 해주면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> timebar_1min.resample('60min').agg({  # 기존의 데이터를 60분 단위로 모아서,
... 'open': 'first', # 그룹 중 가장 첫 번째 open 값을 해당 그룹의 open 값으로 사용
... 'high': 'max', # 그룹 중 가장 큰 high 값을 해당 그룹의 high 값으로 사용
... 'low': 'min', # 그룹 중 가장 작은 low 값을 해당 그룹의 low 값으로 사용
... 'close': 'last', # 그룹 중 가장 마지막 close 값을 해당 그룹의 close 값으로 사용
... 'volume': 'sum', # 그룹 내 volume 값을 모두 더한 값을 해당 그룹의 volume 값으로 사용
... 'value': 'sum' # 그룹 내 value 값을 모두 더한 값을 해당 그룹의 value 값으로 사용
... })
open high ... volume value
t ...
2021-02-11 00:00:00 48384000.0 48840000.0 ... 1288.234569 6.193043e+10
2021-02-11 01:00:00 47910000.0 48266000.0 ... 506.462391 2.428122e+10
2021-02-11 02:00:00 48104000.0 48376000.0 ... 223.599107 1.075957e+10
... ... ... ... ...
2021-02-15 06:00:00 52422000.0 52670000.0 ... 117.880850 6.193310e+09
2021-02-15 07:00:00 52653000.0 52975000.0 ... 223.672317 1.180129e+10
2021-02-15 08:00:00 52823000.0 52847000.0 ... 187.066944 9.853452e+09
[105 rows x 6 columns]