암호화폐 트레이딩 봇
최근 블로그 포스팅이 한 동안 뜸했던 이유는, 어느 날 트레이딩 봇을 만들고 싶은 욕구가 생겨서 여기에 지속적으로 힘을 쓰고 있었기 때문이다. 개발자라면 누구나 한 번쯤은 만들어본다는 이것을, 아직 나는 만들어본 적이 없으니 괜찮은 기회라 여겨 해보기로 했다. 설계를 여러번 수정하다가 이제서야 어느정도 완성도를 보이고 있어 블로그에 적기로 했다. 참고로 개발 언어는 파이썬이 아닌 Go 다. 그 이유는 아래에서하자.
봇 같은 경우 주식 트레이딩은 봇은 아니고, 암호화폐 거래소 중 하나인 업비트에 암호화폐를 주문하고, 조건에 맞는 코인을 감지, 이후 감지된 마켓을 대상으로 전략을 실행할 수 있는 봇을 개발했다. 이미 서문만으로도 봇의 구조가 이미 노출되었지만, 이는 그저 프레임워크를 만들어낸 것 뿐이며 가장 중요한 것은 전략인데, 이는 기업 비밀이라 비공개다. 애초에 수익을 제대로 내고 있지도 않지만.
업비트 개발자 센터
업비트 Open API 사용을 위한 개발 문서를 제공 합니다.업비트 Open API 사용하여 다양한 앱과 프로그램을 제작해보세요.
docs.upbit.com
파이썬이 아닌 Go 언어로 개발한 이유
이 프로젝트는 내가 Go 로 작성한 첫 번째 사이드 프로젝트다. 일반적으로 트레이딩 봇은 파이썬으로 개발된 경우가 많은데, 나같은 경우에는 Go 를 선택했다. Go 를 사용한 이유는 물론 현재 내 주력 언어가 Go 인 것이 가장 큰 이유이기도 하지만, Go 를 봇 개발에 사용했을때 가지는 간편하고 채널을 통한 동시성 제어에서의 이점이 크다고 여겼기 때문이다. 트레이딩 봇에서 여러 마켓의 감시를 위해 고루틴을 사용하여 동시성을 사용할 일은 많은데, 그 예는 설계에서 살펴보도록 하자.
내가 개발한 트레이딩 봇은 오픈소스다. 따라서 봇의 사용법이나 코어 소스코드가 궁금하다면 아래의 깃허브 레포지토리를 확인하자. 이 포스트에서는 봇에 대한 전반적인 설계를 살펴본다.
https://github.com/pronist/upbot
GitHub - pronist/upbot: 암호화폐 트레이딩 봇 (feat. 업비트)
암호화폐 트레이딩 봇 (feat. 업비트). Contribute to pronist/upbot development by creating an account on GitHub.
github.com
설계
봇은 가장 큰 관점에서 보자면, 봇은 업비트 서버의 관점에서 클라이언트라는 점이다. 어떤 서버에 요청을 보내는 클라이언트냐 하면 업비트 API 서버에 보내는 클라이언트라고 볼 수 있다. 어떠한 형태로든 트레이딩 봇은 업비트 서버에 요청을 보내게 된다. 그게 조회가 될 수도 있고 주문을 요청을 하는 것일 수도 있다.
또한 시세를 주기적으로 감시하여 조건에 도달했는지를 판단하는 Detector
, 각 마켓을 대상으로 개별적인 매수/매도 전략을 실행할 수 있는 Strategy
가 고루틴의 주요 사용처다. 이는 서로 독립적으로 돌아간다. Strategy
에서는 조건에 도달하면 업비트 API 서버에 주문을 보내기 때문에 다른 문맥에서 독립적으로 동작해도 아무런 영향이 없다. 봇의 전반적인 구조는 다음과 같다.
다이어그램을 보면 알겠지만, 봇은 중간에서 중개인의 역할을 수행하게 되며 Detector
가 특정 조건에 해당하는 종목을 찾아서 봇에게 보고를 하면, 봇은 코인을 추상화한 Coin
객체를 생성하고 매수/매도를 위한 Strategy
에 생성한 Coin
객체를 전달하여 실행하게 될 것이다. 여기서 Detector
는 별개의 고루틴에서 동작, 봇이 실행하는 전략들도 모두 별개의 고루틴에서 실행되며 독립적으로 조건을 검증하여 매수/매도를 진행한다. 거의 동시에 여러 개의 마켓에 대해 전략을 실행할 수 있다.
예를 들어 Detector
가 특정 조건을 만족한 종목인 KRW-BTC 를 발견하여 봇에 보고하면, 봇은 BTC 코인에 해당하는 Coin
객체를 생성하고 KRW-BTC 마켓이 Strategy
에 따라 매수/매도 될 수 있도록 하게하는 것이다. 그래서 주목해볼만한 부분은 결론적으로 트레이딩 봇이라는 것이 의도대로 동작하기 위해서는 종목 선정(Detecting)과 매수/매도 전략(Strategy)이라는 두 가지의 주요 핵심 알고리즘이 있다는 것이며 이에따라 적절한 종목선정과 전략에 따라 봇의 성과가 결정된다는 것이다.
업비트 API 클라이언트
봇은 위에서 언급했듯 업비트의 API 서버에 요청을 보내는 클라이언트다. 따라서 업비트 API 에 요청을 보낼 수 있는 클라이언트 래핑 객체가 필요하게 된다. 물론 이 부분은 업비트 API 문서에 따라 작성된 것이기 때문에 그렇게 중요하지는 않지만, 실제로 업비트 API 서버에 요청을 보내는 역할을 하므로 짤막하게나마 이야기해본다.
업비트 API 서버는 두 종류로 나눌 수 있는데, Jwt
를 포함하여 요청을 보내야 하는 일반적인 Client
와 그저 Get
요청만 보내도 정보를 얻을 수 있는 QuotationClient
로 분리된다.
type Client
자산, 주문 요청을 업비트 서버에 보내기 위해 사용하는 클라이언트다. 당연하겠지만 여기에는 AccessKey, SecretKey 가 포함되어야 한다.
type Client struct {
*http.Client
AccessKey string
SecretKey string
}
type QuotationClient
QuotationClient
는 단순한 Get
요청을 위해 사용한다. 이를 통해 종목에 대한 Tick, Trades 를 얻어오는 등 인증이 필요하지 않은 단순한 정보들을 얻어올 수 있다. 따라서 http.Client
만을 가진다.
type QuotationClient struct {
*http.Client
}
이렇게 선언된 두 개의 클라이언트는 Bot
을 통해 접근할 수 있도록 하였다. 따라서 Client, QuotationClient
를 통해 업비트 서버에 요청을 보낼 수 있게된다.
트레이딩 봇
type Bot
Bot
은 main
고루틴에서 사용되며 Bot.Run()
이라는 메서드를 main()
함수에서 호출할 것이다. 먼저 Bot
구조체는 다음과 같이 생겼다. 위에서 언급한 것처럼 클라이언트의 역할도 한다는 것을 잊어서는 안 된다.
type Bot struct {
*client.Client
*client.QuotationClient
Accounts Accounts
Strategies []Strategy
}
Accounts, Strategy
타입은 모두 인터페이스다. 특히 Accounts
의 경우, 업비트는 기본적으로 모의투자를 지원하지 않는다. 따라서 안전하게 전략이 동작하는지 실험을 할 수 있어야 하는데, 그럴때 필요한 것이 프로그램에서 임의로 만든 테스트용 계정이다. 이는 실제 업비트 계정이 아니며 비슷한 동작을 하도록 구현이 된 것 뿐이다. 따라서 미묘한 차이가 발생한다.
또한 Bot
에서는 미리 마켓에 사용할 전략을 가지고 있다. Detector
가 조건에 도달한 마켓을 발견하게 되면 해당 마켓에 Strategies
에 있는 전략들을 실행하게 된다.
main()
Bot
을 호출하는 main()
함수는 아래와 같이 작성된다. 계정을 임의로 생성하여 전략을 테스트할 수 있다.
func main() {
///// 봇에 사용할 전략을 설정한다.
b := bot.New([]bot.Strategy{
// https://wikidocs.net/21888
&bot.PenetrationStrategy{},
})
/////
///// 봇에 사용할 계정을 설정한다.
//acc, err := bot.NewUpbitAccounts(b)
acc, err := bot.NewFakeAccounts("accounts.db", 55000.0) // 테스트용 계정
if err != nil {
logrus.Fatal(err)
}
b.SetAccounts(acc)
/////
logrus.Panic(b.Run())
}
.Run()
.Run()
메서드는 main
고루틴이 실행하는 메서드이며, Detector
의 보고를 받고, 전략을 실행하는 핵심 메서드다. Detector
에게 보고를 받을 때는 자연스럽게 채널을 사용한다. 참고로 아래의 코드가 실제 돌아가고 있는 봇의 코드랑 동일한 것이 아니다. 핵심적인 코드만을 가져와 포스트하기 편하도록 짜집기했다.
// 추적할 종목에 대한 조건이다.
func Predicate(t map[string]interface{}) bool {
return true
}
func (b *Bot) Run() error {
// 전략의 사전 준비를 해야한다.
for _, strategy := range b.strategies {
log.Logger <- log.Log{
Msg: "Register strategy...",
Fields: logrus.Fields{"strategy": reflect.TypeOf(strategy).String()},
Level: logrus.DebugLevel,
}
if err := strategy.register(b); err != nil {
return err
}
}
///// 디텍터
d := newDetector()
go d.run(b, predicate) // 종목 찾기 시작!
/////
for tick := range d.d {
// 디텍팅되어 가져온 코인에 대해서 전략 시작 ...
market := tick["code"].(string)
// 코인 생성
coin, err := newCoin(b.Accounts, market[4:], static.Config.TradableBalanceRatio)
if err != nil {
return err
}
// 전략에 주기적으로 가격 정보를 보낸다.
go b.tick(coin)
for _, strategy := range b.Strategies {
if err := strategy.boot(b, coin); err != nil {
return err
}
go b.strategy(coin, strategy)
}
}
}
추가적으로 Detector.run()
의 파라매터에 predicate
가 사용된 것이 있는데, 저것은 함수이며 디텍터가 찾을 종목에 대한 조건을 명시한다. 해당 함수가 true
를 반환하면 조건에 맞는 종목으로 판단하며 Detector.d
채널에 신호를 보낸다. Detector.run()
에서는 내부적으로 업비트 웹소켓 서버에 요청을 보내 가격을 얻어오고 조건을 처리한다.
Bot.tick()
메서드는 coin
구조체에 정의되어 있는 t
채널에 가격 정보를 보내고 이를 Strategy
에서 소모한다. Strategy
에서 직접 가격정보를 얻어와도 되지만, 요청의 수가 너무 많아지면 업비트 서버의 제약에 따라 요청이 거절된다. 업비트 서버의 제한은 초당 10번의 요청으로 파악되었다.
위의 다이어그램은 Bot
이 Detector.run()
를 실행하면 해당 메서드가 Detector.d
채널로 틱을 보내고 그것을 Bot
이 소비하는 모습을 보인다. 또한 Bot.tick()
이 실행되면 Coin.t
채널에 틱을 보내고 Strategy
가 이를 소비하게 된다. Strategy
가 소비를 하는 모습은 다음과 같다.
.strategy(*coin, Strategy)
func (b *Bot) strategy(coin *Coin, strategy Strategy) {
for {
// 이러한 tick 정보가 있다면 현재 시점의 전일 대비 가격 변화율, 마켓의 이름과 같은 정보를 얻어올 수 있다.
// https://docs.upbit.com/docs/upbit-quotation-websocket#%ED%98%84%EC%9E%AC%EA%B0%80ticker-%EC%9D%91%EB%8B%B5
t := <-coin.t
// 전략을 실행한다.
if _, err := strategy.run(b.Accounts, coin, t); err != nil {
panic(err)
}
// 전략이 너무 자주 실행되지 않도록 해야한다.
time.Sleep(time.Second * 1)
}
}
type Accounts
Accounts 는 인터페이스다. Accounts
는 업비트 계정을 포함한 테스트용 계정이 구현해야 할 메서드를 가진다. Accounts
가 가져야 하는 메서드 중 중요한 것이 바로 .order()
다. 주문은 봇, 또는 사람이 하지만 논리적으로 계정을 사람, 또는 봇과 동일시하여 Accounts
가 특정 코인에 대해 매수/매도 주문을 낼 수 있다.
type Accounts interface {
// order 메서드는 주문을 하되 Config.Timeout 만큼이 지나가면 주문을 자동으로 취소한다.
// 매수/매도에 둘다 사용한다.
order(b *Bot, c *coin, side string, volume, price float64) (bool, error)
// 내부에 있는 upbit.API 에서의 접근을 위해 accounts 를 반환해야 한다.
accounts() ([]map[string]interface{}, error)
}
.order(*Bot, *Coin, string, float64, float64) (bool, error)
오더에서는 실제로 업비트 계정에서는 주문을 요청하고, 테스트 계정에서는 내부의 자산 현황을 갱신하게 된다. 여기서 살펴볼 것은 실사용 계정에서 주문을 넣었으나 체결되지 않고 계속 기다리기만 하면 트래킹 중인 해당 마켓의 전략 고루틴이 락이 되어버릴 수도 있다는 점이다. 따라서 타이머를 두고 체결을 기다렸다가 체결이 되지 않으면 주문을 캔슬한다.
func (acc *Accounts) order(b *Bot, coin *coin, side string, volume, price float64) (bool, error) {
// 주문...
timer := time.NewTimer(time.Second * 30)
go acc.wait(b, done, uuid)
select {
// 주문이 체결되지 않고 무기한 기다리는 것을 방지하기 위해 타임아웃을 지정한다.
case <-timer.C:
// 주문 취소
_, err := b.Client.Call("DELETE", "/order", struct {
Uuid string `url:"uuid"`
}{uuid})
if err != nil {
return false, err
}
// ...
}
// 계정 갱신...
}
여기서 log.Logger
는 로그를 보내기 위한 채널이다. 이전에 적지는 않았지만, 로그 채널은 봇을 실행하기 이전에 초기화를 별도로 진행한다. 별도로 아래에서 언급하지는 않겠지만 나온김에 이야기했다. 또한 static.Config
객체는 글로벌 객체이며 config.yml
로 부터 Timeout
설정을 얻어와서 매핑한다.
type Strategy
Strategy
또한 인터페이스다. 해당 인터페이스를 만족하는 모든 전략은 봇에서 사용할 수 있도록 구성되었다. 여기서 .register()
는 전략을 실행하기 전에 준비해야 할 것을, .run()
메서드는 전략을 진행한다.
type Strategy interface {
register(bot *Bot) error // 봇이 실행될 때 전략이 최초로 등록될 때
boot(bot *Bot, c *coin) error // 코인을 생성하고 전략을 실행하기 직전
run(bot *Bot, c *coin, t map[string]interface{}) (bool, error) // 전략
}
마치며
트레이딩 봇을 만드는 과정은 흥미롭다. 프레임워크에 해당하는 틀은 어느정도 구성되었기에 이제 전략을 재미나게 생각하는 일만 남았다. 봇은 사실 전략이 제일 중요하다. 전략에 따라 수익이 날 수도 있고 안 날수도 있기 때문이다.
그러나 내가 이렇게 까지 구조적으로 봇을 작성한 이유는 이것을 단순 경험만으로 끝낼게 아니라 무언가 결과를 도출해볼 것이기 때문이다. 또한 이 프로젝트는 나의 첫번째 Go 언어 사이드 프로젝트라는 점에서 의미가 있으며 부가적인 써드파티 라이브러리들을 사용해볼 기회또한 있어서 나름 괜찮은 프로젝트라고 생각한다.
더 읽을거리
Webpack 3 에서 Webpack 5 으로 바꾸기 위해 해야 할 일들