본문 바로가기

가상화폐 시리즈

Binance API 선물거래 - MACD_EMA 전략 구현

728x90

https://www.youtube.com/watch?v=Y-HFJkeJyc4

 

이번 글은 위 영상을 기반으로 작성하였습니다.

 

Preview

가상화폐 자동매매 프로그램을 접하기 가장 좋은 영상이지 않을까 싶습니다. 사용된 전략도 매우 간단하고요!

바이낸스 계정을 만들고 리플을 입금하고 API세팅하는 부분은 다른 블로그를 참고하시길 바랍니다.

 

코인 거래를 시작하려면 가장 먼저 드는 생각은 어떤 코인을 살까입니다. 어떤 기준으로 코인을 사고팔아야 할지 고민하다 보면 막막합니다.

그래서 이번 글에서는 그런 분들에게 좋은 강의와 이에 대한 설명을 해보려고 합니다.

 

MACD-EMA 전략은 무엇일까?

MACD는 Moving Average Convergence Divergence입니다. Moving Average는 이동평균선입니다. 특정 기간 동안의 주가를 그 기간의 일수나 틱수로 나눈 평균가격입니다. 이런 이동평균선에 대한 Convergence(수렴), Divergence(발산)입니다. 즉, 이동평균선이 어떻게 진행되는지를 나타내는 지표입니다. 

 

평균 12일 이평선과 26일 이평선을 사용하며 기준이 되는 시그널은 9일 이평선을 사용한다고 합니다. 수치로 나타내면 다음과 같습니다.

MA12(12일 이평선) = (P1 + P2 +... + P12) / 12

MA26(26일 이평선) = (P1 + P2 +... + P26) / 26

MA9(9일 이평선) = (P1 + P2 + ... + P9) / 9

 

MACD 라인은 MA12 - MA26으로 단기이평선에서 장기이평선을 뺀 값입니다. 이 값이 양수라면 단기 이평선이 장기 이평선보다 위에 있는 정배열 상태입니다. 음수라면 단기 이평선이 장기 이평선보다 아래에 있는 역배열 상태입니다. 

 

MACD 지표의 값은 MACD라인 - MA9입니다. 수식으로 하면 MA12 - MA26 - MA9가 됩니다. 보통 MACD라인이 MA9선을 교차하는 지점을 매수 혹은 매도 지점으로 보고 있는데 음수에서 양수로 전환되는 지점을 매수 시점, 양수에서 음수로 전환되는 지점을 매도 시점으로 봅니다. 

 

EMA는 Exponential Moving Average의 약자로 지수이동평균선을 의미합니다. 앞서 설명한 이동평균선에 가중치를 곱한 것인데 가중치는 최근 종가가격에 대한 조정입니다. 즉, 최근 값이 더 의미 있다고 보는 것이죠.

이제 이 지표들을 활용해서 어떻게 코드를 짜는지 살펴보겠습니다.

 

코드구현

import configparser
from binance.um_futures import UMFutures
import ta
import pandas as pd
from time import sleep
from binance.error import ClientError

config = configparser.ConfigParser()
config.read('Config/config.ini')
api_key = config['binance']['api_key']
api_secret = config['binance']['api_secret']

client = UMFutures(key=api_key, secret=api_secret)

tp = 0.01 #1% 오르면 정리매매
sl = 0.01 #1% 떨어지면 정리매매
volume = 50
leverage = 10 #10배율이므로 한 계약 단위는 5USDT
type = 'ISOLATED' #격리마진

바이낸스로부터 발급받은 api키를 사용하여 binance-futures-connector 패키지에 연결합니다. 

Cross (교차마진)은 코인에 대한 증거금뿐만 아니라 내 계좌 잔고를 증거금으로 잡는 것이기 때문에 매우 위험합니다. 따라서 격리마진으로 하는 게 좋습니다.

 

def get_balance_usdt():
    try:
        response = client.balance(recvWindow=6000)
        for elem in response:
            if elem['asset'] == 'USDT':
                return float(elem['balance'])
            
    except ClientError as error:
        print(
            "Found error. status: {}, error code: {}, error message: {}".format(
                error.status_code, error.error_code, error.error_message
            )
        )


print("My balance is : ", get_balance_usdt())

 

현재 본인의 잔고를 확인할 수 있습니다.

def get_tickers_usdt():
    tickers = []
    resp = client.ticker_price()
    for elem in resp:
        if 'USDT' in elem['symbol']:
            tickers.append(elem['symbol'])
    return tickers

현재 거래 가능한 USDT 코인목록이 조회됩니다.

def klines(symbol):
    try:
        resp = pd.DataFrame(client.klines(symbol, '1h'))
        resp = resp.iloc[:, :6]
        resp.columns = ['Time','Open','High', 'Low','Close','Volume']
        resp = resp.set_index('Time')
        resp.index = pd.to_datetime(resp.index, unit='ms')
        resp = resp.astype(float)
        return resp
    except ClientError as error:
        print(
            "Found error. status: {}, error code: {}, error message: {}".format(
                error.status_code, error.error_code, error.error_message
            )
        )

1시간 단위의 시세를 조회합니다. 

대략 500시간, 일수로 환산하면 1개월이 좀 안 되는 기간입니다.

#코인의 허용되는 가격 소수점 자리수를 반환합니다.
def get_price_precision(symbol):
    resp = client.exchange_info()['symbols']
    for elem in resp:
        if elem['symbol'] == symbol:
            return elem['pricePrecision']
        
#코인의 허용되는 수량 소수점 자리수를 반환합니다.
def get_qty_precision(symbol):
    resp = client.exchange_info()['symbols']
    for elem in resp:
        if elem['symbol'] == symbol:
            return elem['quantityPrecision']

주문을 넣을 때 해당 코인이 허용하는 소수점자릿수까지만 주문을 넣을 수 있습니다. 따라서 거래 정보를 조회해 이 값을 가져옵니다.

 

def open_order(symbol, side):
    price = float(client.ticker_price(symbol)['price'])
    qty_precision = get_qty_precision(symbol) #주문 가능한 수량 소수점 자리수를 가져옵니다.
    price_precision = get_price_precision(symbol) #주문 가능한 가격 소수점 자리수를 가져옵니다.
    qty = round(volume /price, qty_precision)
    if side == 'buy':
        try:
            resp1 = client.new_order(symbol=symbol, side='BUY', type='LIMIT', quantity = qty, timeInForce ='GTC', price=price)
            print(symbol, side, "placing order")
            print(resp1)
            sleep(2)
            sl_price = round(price - price*sl, price_precision) #stop loss
            resp2 = client.new_order(symbol=symbol, side='SELL', type='STOP_MARKET', quantity=qty, timeInForce='GTC', stopPrice=sl_price)
            print(resp2)
            sleep(2)
            tp_price = round(price + price*tp, price_precision) #take profit
            resp3 = client.new_order(symbol=symbol, side='SELL', type='TAKE_PROFIT_MARKET', quantity=qty, timeInForce='GTC', stopPrice=tp_price)
            print(resp3)
        except ClientError as error:
            print(
                "Found error. status: {}, error code: {}, error message: {}".format(
                    error.status_code, error.error_code, error.error_message
                )
            )
    elif side == 'sell':
        try:
            resp1 = client.new_order(symbol=symbol, side='SELL', type='LIMIT', quantity = qty, timeInForce ='GTC', price=price)
            print(symbol, side, "placing order")
            print(resp1)
            sleep(2)
            sl_price = round(price + price*sl, price_precision)
            resp2 = client.new_order(symbol=symbol, side='BUY', type='STOP_MARKET', quantity=qty, timeInForce='GTC', stopPrice=sl_price)
            print(resp2)
            sleep(2)
            tp_price = round(price - price*tp, price_precision)
            resp3 = client.new_order(symbol=symbol, side='BUY', type='TAKE_PROFIT_MARKET', quantity=qty, timeInForce='GTC', stopPrice=tp_price)
            print(resp3)
        except ClientError as error:
            print(
                "Found error. status: {}, error code: {}, error message: {}".format(
                    error.status_code, error.error_code, error.error_message
                )
            )

 

여기서 사용된 주문 방법은 세 가지가 있습니다. 

각 주문 방법은 지정가(LIMIT) 주문, 손절 가격 설정(STOP MARKET), 익절 가격 설정(TAKE_PROFIT_MARKET)으로 동일한 코인에 대한 세 가지 주문을 넣습니다. 대부분 LIMIT 주문이 체결되고 나머지는 open orders로 남아있습니다.  전략대로 주문 가격에 1% 변동이 있는 경우 정리매매를 합니다. 레버리지를 10배로 했으니 사실상 10% 등락이 있는 경우 포지션을 정리하겠지요.

 

def check_position():
    positions = []
    try:
        resp = client.get_position_risk()
        for elem in resp:
            if float(elem['positionAmt'])!= 0:
                positions.append(elem)
        
        return positions
    except ClientError as error:
            print(
                "Found error. status: {}, error code: {}, error message: {}".format(
                    error.status_code, error.error_code, error.error_message
                )
            )

현재 포지션으로 잡혀있는 코인 목록을 반환합니다.

def close_open_orders(symbol):
    try:
        response = client.cancel_open_orders(symbol = symbol, recvWindow=2000)
        print(response)
    except ClientError as error:
            print(
                "Found error. status: {}, error code: {}, error message: {}".format(
                    error.status_code, error.error_code, error.error_message
                )
            )

현재 포지션을 정리합니다.

 

def check_macd_ema(symbol):
    kl = klines(symbol)
    if ta.trend.macd_diff(kl.Close).iloc[-1] > 0 and ta.trend.macd_diff(kl.Close).iloc[-2] < 0 \
        and ta.trend.ema_indicator(kl.Close, window=200).iloc[-1] < kl.Close.iloc[-1]:
            return 'up'
    elif ta.trend.macd_diff(kl.Close).iloc[-1] < 0 and ta.trend.macd_diff(kl.Close).iloc[-2] > 0 \
        and ta.trend.ema_indicator(kl.Close, window=200).iloc[-1] > kl.Close.iloc[-1]:
            return 'down'
    
    else : 
        return 'none'

 

앞서 설명한 것처럼 MACD 지표 값이 음수에서 양수로 전환되는 지점(매수시점, 단기이평선이 장기이평선을 교차하는 지점)과 지수이동평균값이 현재 종가보다 낮은 경우를 매수시점으로 보고 있으며, 그 반대는 매도시점으로 판단합니다. MACD와 EMA는 파이썬 패키지 ta를 사용해서 값을 얻습니다.

https://technical-analysis-library-in-python.readthedocs.io/en/latest/ta.html#ta.trend.macd_diff

https://technical-analysis-library-in-python.readthedocs.io/en/latest/ta.html#ta.trend.ema_indicator

 

order = False
symbol = ''
symbols = get_tickers_usdt()

while True:
    positions = check_position()
    print(f'You have {len(positions)} opened positions')
    if len(positions) == 0:
        order = False
        if symbol != '': #포지션이 청산되면 open orders 주문을 취소합니다.
            close_open_orders(symbol)

    if order == False:
        for elem in symbols : 
            signal = check_macd_ema(elem)
            if signal == 'up':
                print('Found BUY signal for ', elem)
                set_mode(elem, type)
                sleep(1)
                set_leverage(elem, leverage)
                sleep(1)
                print('Placing order for ', elem)
                open_order(elem, 'buy')
                symbol = elem
                order = True
                break

            if signal == 'down':
                print('Found SELL signal for ', elem)
                set_mode(elem, type)
                sleep(1)
                set_leverage(elem, leverage)
                sleep(1)
                print('Placing order for ', elem)
                open_order(elem, 'sell')
                symbol = elem
                order = True
                break
    print("Waiting 60 secs")
    sleep(60)

 

위 코드를 실행하면 60초에 한번 주문할 만한 코인을 탐색합니다. 조건은 MACD라인이 MA9라인을 교차하고 EMA가격이 종가보다 높거나 낮은 경우입니다. 실행하면 다음과 같은 결과를 얻을 수 있습니다.

You have 0 opened positions
Found BUY signal for  STEEMUSDT
Found error. status: 400, error code: -4046, error message: No need to change margin type.
{'symbol': 'STEEMUSDT', 'leverage': 10, 'maxNotionalValue': '100000'}
Placing order for  STEEMUSDT
STEEMUSDT buy placing order
{'orderId': 686366604, 'symbol': 'STEEMUSDT', 'status': 'NEW', 'clientOrderId': 'NxtwxIxv5UZYSL9QJnoQ3n', 'price': '0.198610', 'avgPrice': '0.00', 'origQty': '252', 'executedQty': '0', 'cumQty': '0', 'cumQuote': '0.000000', 'timeInForce': 'GTC', 'type': 'LIMIT', 'reduceOnly': False, 'closePosition': False, 'side': 'BUY', 'positionSide': 'BOTH', 'stopPrice': '0.000000', 'workingType': 'CONTRACT_PRICE', 'priceProtect': False, 'origType': 'LIMIT', 'priceMatch': 'NONE', 'selfTradePreventionMode': 'NONE', 'goodTillDate': 0, 'updateTime': 1721219807897}
{'orderId': 686366668, 'symbol': 'STEEMUSDT', 'status': 'NEW', 'clientOrderId': 'zT5dibwrmH7RadBGEQ8lgW', 'price': '0.000000', 'avgPrice': '0.00', 'origQty': '252', 'executedQty': '0', 'cumQty': '0', 'cumQuote': '0.000000', 'timeInForce': 'GTC', 'type': 'STOP_MARKET', 'reduceOnly': False, 'closePosition': False, 'side': 'SELL', 'positionSide': 'BOTH', 'stopPrice': '0.196624', 'workingType': 'CONTRACT_PRICE', 'priceProtect': False, 'origType': 'STOP_MARKET', 'priceMatch': 'NONE', 'selfTradePreventionMode': 'NONE', 'goodTillDate': 0, 'updateTime': 1721219809949}
{'orderId': 686366684, 'symbol': 'STEEMUSDT', 'status': 'NEW', 'clientOrderId': 'R34wMmdTDKX7mFoEPrcNhs', 'price': '0.000000', 'avgPrice': '0.00', 'origQty': '252', 'executedQty': '0', 'cumQty': '0', 'cumQuote': '0.000000', 'timeInForce': 'GTC', 'type': 'TAKE_PROFIT_MARKET', 'reduceOnly': False, 'closePosition': False, 'side': 'SELL', 'positionSide': 'BOTH', 'stopPrice': '0.200596', 'workingType': 'CONTRACT_PRICE', 'priceProtect': False, 'origType': 'TAKE_PROFIT_MARKET', 'priceMatch': 'NONE', 'selfTradePreventionMode': 'NONE', 'goodTillDate': 0, 'updateTime': 1721219812012}
Waiting 60 secs

 

STEEMUSDT 코인이 매수 시그널을 가지고 있어 0.198610 달러에 매수를 했고 가격폭 1% 전후로 Stop Lose 매도 주문과 Take Profit 매도 주문을 넣었습니다. 

Positions

 

Open Orders

 

10배의 레버리지 주문을 넣었기 때문에 가격 변동이 정말 무섭습니다. 백테스팅으로 이 전략의 유효성을 검증하는 단계가 남아있지만 우선 저는 실전에서 겪어보고 싶어 며칠 동안 이 전략으로 투자를 해본 결과 처음 며칠은 수익을 50% 정도 봤지만 이후에는 다시 본전으로 돌아왔습니다. 더 견고하고 정교한 전략이 필요할 것 같습니다.. 끌끌..