Pull to refresh

В поисках «Годзиллы». Нейросети и прогнозирование котировок на основе биржевых и «внешних» данных

Machine learningResearch and forecasts in ITCryptocurrencies
Sandbox
🔥 Technotext 2020


Эта работа вдохновлена статьей «Мечтают ли нейросети об электроденьгах?», где автор без преувеличения талантливо в своей доходчивости объясняет, почему использование искусственных нейросетей на голых биржевых данных не приводит к успеху. Вот особенно, на мой взгляд, удачный отрывок:


«Цена не формирует сама себя… Если рынок выразить как метафоричное озеро, то биржевой график это лишь рябь на воде. Может быть это ветер подул, может камень в воду бросили, может рыбы плеснула, может Годзилла прыгает в 200 километрах на батуте. Мы видим лишь рябь.»

Действительно, пытаться предсказать поведение ряби на следующий день, имея в распоряжении только лишь данные о поведении ряби в прошлом, видится, как минимум, самонадеянным. Не тот масштаб модели. Поведение водной глади формируется за счет множества внешних и внутренних по отношению к водоему факторов. И вот на этом моменте мое любопытство не позволило мне пройти мимо. А что если все-таки поработать с этими факторами? Что получится, если учесть их в модели данных, предназначенных для «кормежки» нейросети?


«Но как же учесть миллионы, если не миллиарды возможных факторов влияющих на наш метафорический водоем?» — спросите вы. А я отвечу, что нас не интересуют ни движение мальков, ни случайно упавший с близлежащей скалы камушек, ни мерное покачивание удочки Дяди Вити-рыбака. Нас интересует Годзилла.


Справедливости ради стоит отметить, что автор вдохновившей меня статьи не исключает возможности увеличения прогностической точности нейросети, если учесть все множество отмеченных факторов. Тем не менее, в упомянутой статье рассуждения об их природе сводятся к пределу детализации — к человеку. А именно, к учету состояния (психического, психологического, физиологического, финансового и т.д.) каждого конкретного участника рынка. Этооо… дорого. Дорого настолько, что неразумно.


Я же в своей работе обращаюсь к гипотезе, что поведение участников торгов предваряют события, которые это поведение определяют. Я предполагаю что в паре «События»/«Поведение пользователей» «События» все же метафорическая собака, а «Поведение пользователей» — метафорический хвост, и собака, как положено, виляет хвостом, а не наоборот. Вот это самое предположение я и предлагаю нам с вами проверить.


Для тех, кто хочет сразу перейти к делу и поэкспериментировать самостоятельно — весь код и данные содержатся на GitHub вот тут.


Начнем


Сформулируем вопрос, на который будем искать ответ, более строго: «Увеличится ли эффективность прогнозирования поведения цены финансового актива нейронной сетью, при включении результатов анализа событийного потока в состав модели данных?»


Событие — понятие смутное и вольно трактуемое, потому будем его принудительно определять. Под событиями здесь и далее предлагаю понимать тематические новости на популярных ресурсах и транзакционную активность рассматриваемого финансового инструмента.


По отношению к анализу новостей, очевидно, разумным решением будет применить методы sentiment analisys, а в отношении транзакционной активности… «Стоп. Что за зверь эта ваша «транзакционная активность»?» — повис вопрос на лице читателя. Тут мы подошли к моменту, когда пора определить наш объект исследования.


Среди всего многообразия активов, для анализа я выбрал биткоин, и на то есть причина. Имя причины — блокчейн. Мне стало до жути интересно — как изменится предиктивная способность сети, если в модель данных мы добавим еще и динамику активности в самой сети биткоин? Сможем ли мы таким образом выявить «подводное течение» в нашем метафорическом водоеме? В конце концов, вдруг Годзилла в лучших традициях классического кинематографа прячется под водой?


Активность справедливо измерять в объемах переданных по сети криптомонет, информация о которых содержится в записях транзакций в блоках, собственно, блокчейна. Транзакция, в свою очередь, состоит из «входов» — это сумма монет, которую определенный кошелек принял, и «выходов» —это суммы монет, которые были (а) отправлены с определенного кошелька, (б) были возвращены на кошелек-отправитель в качестве «сдачи», (в) были уплачены в качестве комиссии майнерам. Поскольку суммы, указанные в качестве выходов транзакций на мой взгляд содержат больше информаци, в качестве мерила активности сети предлагаю использовать именно их.


Итак, прежде, чем отправиться в увлекательное путешествие, заглянем в список того, что должно находится в нашем инвентаре:


  1. Данные о биржевых торгах;
  2. Данные об активности в биткоин-сети;
  3. Преобразованный поток тематических новостей;
  4. TensorFlow + Keras — one love.

Пункт 3 — предмет для описания в ближайшем будущем, а вот со всем остальным мы поработаем сейчас.


Поиск данных


Первое — данные о биржевых торгах. Найти их не должно быть тяжелой задачей. И действительно, быстрая пробежка по первым строкам результатов Google привела меня на investing.com. Здесь забираем CVS с максимальной глубиной во времени и дневной детализацией.


Пометка на полях: пользоваться рекомендую данными Investing.com Bitcoin Index, поскольку автоматически рекомендуемый Bitfinex Bitcoin US Dollar содержит бессовестные пропуски данных недельных масштабов.


В чистом виде информацию об объеме выходов на каждый конкретный день мне найти не удалось, но не беда. Настойчивый поиск по теме привел на ресурс MIT, где инженеры кропотливо собирали детальную информацию по блокчейну биткойна в различных разрезах. Сам ресурс тут.


Из всего представленного перечня датасетов, нам понадобятся:


  • bh.dat.gz — содержит хэш-суммы блоков. Формат: идентификатор блока (blockID), хэш-сумма (hash), временная метка записи блока в блокчейн (block_timestamp), количество транзакций в блоке (n_txs);
  • tx.dat.xz — содержит информацию о транзакциях. Формат: идентификатор транзакции (txID), идентификатор блока, в который записана транзакция (blockID), количество входов (n_inputs), количество выходов (n_outputs);
  • txout.dat.xz —содержит информацию о выходах для каждой транзакции. Формат: идентификатор транзакции (txID), порядковый номер (output_seq), идентификатор адреса, куда отправляется выход (addrID), объем отправленных монет (sum).

Сопоставим данные файлов друг с другом следующим образом:


Код для сопоставления таблиц
import pandas as pd

#Loading bh.dat
mit_data = pd.read_table('.../bh.dat',
                         header=None, names=['blockID', 'hash','block_timestamp', 'n_txs'])
mit_data['block_timestamp'] = pd.to_datetime(mit_data['block_timestamp'], unit='s')
#Loading info about output of transactions
out_txs_all = pd.read_table('.../txout.dat',header=None,
                           names=['txID', 'output_seq','addrID', 'sum'])
#Loading info transaction overview dataset
mapping_dataset = pd.read_table('.../tx.dat', #The path to the tx.dat file
                                header=None, names=['txID', 'blockID','n_inputs', 'n_outputs'])
#Dropping needless columns in outputs dataset and summing up outputs values groupping them by 'txID'
out_txs_all.drop('output_seq',axis=1,inplace=True)
out_txs_all.drop('addrID',axis=1,inplace=True)
out_txs_all = out_txs_all.groupby('txID').sum().reset_index()

#Dropping needless columns in transactions dataset and adding outputs values
mapping_dataset.drop('n_inputs', axis=1,inplace=True)
mapping_dataset.drop('n_outputs', axis=1,inplace=True)
mapping_dataset['sum_outs'] = out_txs_all['sum']

#Now we can dropp 'txID' column since it's useless now.
#Then we group mapping_df by blockID and addup all sums to get btc_sum per particular block
mapping_dataset.drop('txID', axis=1, inplace=True)
mapping_dataset=mapping_dataset.groupby('blockID').sum().reset_index()

#Prepare mit_data to the next manipulations
mit_data.drop('hash', axis=1,inplace=True)
mit_data.drop('n_txs', axis=1,inplace=True)

#Now we just can get dates from sliced block_timestemp dataset and concatinate
#it with mapping_df since they have equals sizes and contains similar blocks.
mapping_dataset['Date'] = mit_data['block_timestamp']
mapping_dataset['sum_outs'] = mapping_dataset['sum_outs'].apply(lambda x: x/100000000) #Getting amount of bitcoins instead of satoshies

#Finally we are making a csv file
mapping_dataset.to_csv('../filename.csv', index=False) 

На выходе получаем таблицу суммарного объема выходов, записанных в блокчейн биткоина с 17.07.2010 по 08.02.2018. И вроде все чудесно, только покоя не дает тот факт, что данные ограничиваются 08 февраля 2018 года. А ведь дальше занимательный период волатильности, начавшегося как раз в феврале 2018 года. А волатильность — это источник информации. Разбрасываться информацией — кощунство. Потому обращаемся к API blockchain.com, потому как более удобного варианта я не нашел.


Получаем недостающие данные следующим образом:


Код для парсинга блоков через API Blockchain.com
import requests
import json
# Makeing timeline in unix time
limit_day = pd.to_datetime('2018-02-09')
datelist = pd.date_range(limit_day, periods=733).to_list()
date_series = pd.DataFrame(data=(datelist), columns=['Date'])
dt = pd.DatetimeIndex(date_series['Date']).astype(np.int64)//1000000
unix_mlseconds_lst = dt.to_list()

#Getting blocks hash list with timestamps
blocks_lst = []
for j in unix_mlseconds_lst:
    request = requests.get('https://blockchain.info/blocks/'+str(j)+'?format=json')
    parse_result = json.loads(request.content)
    blocks_lst.append(parse_result['blocks'])

#Parsing json content for the final dataset
blockID = []
hashID = []
timestamp = []
for d_list in blocks_lst:
    for dictionary in d_list:
        blockID.append(dictionary['height'])
        hashID.append(dictionary['hash'])
        timestamp.append(dictionary['time'])

#Makeing additional bh-dataset
additional_bh = pd.DataFrame(data=(blockID,hashID,timestamp)).T
additional_bh.columns=['blockID','hash','timestamp']
additional_bh['timestamp']=pd.to_datetime(additional_bh['timestamp'], unit='s')

#Getting info about additional outputs
dates_out_sums = {}
for indx in range(len(additional_bh)):
    request = requests.get('https://blockchain.info/rawblock/'+str(additional_bh['hash'][indx])) #Getting all info about block by it's hash
    parse_result = json.loads(request.content)
    block_outs_sum=[]
    for i in parse_result['tx']: #Running through all txs to sum up all outputs
        intermid_out_sum_values = []
        for j in i['out']:
            intermid_out_sum_values.append(j['value'])
        block_outs_sum.append(sum(intermid_out_sum_values))
    dates_out_sums[bh['timestamp'][indx]] = sum(block_outs_sum)

#Making dataframe of additional outputs
dates_out_sums_lst = dates_out_sums.items()
out_txs = pd.DataFrame(dates_out_sums_lst, columns=['Date', 'out_sums'])
out_txs['out_sums']=out_txs['out_sums'].apply(lambda x: x/100000000) #Making a series of bitcoins instead of satoshies
out_txs.to_csv('.../Data/additional_outs_dated(2018-02-09_2018-04-28).csv', index=False)

Пометка на полях: качать информацию с blockchain.com оказалось делом, мягко говоря, небыстрым. В процессе работы над статьей, наряду с лопнувшим терпением, я получил данные из 11949 дополнительных блоков, что соответствует двум дополнительным месяцам. Я посчитал, что этого будет достаточно.


Теперь объединяем полученные датасеты, и мы готовы к этапу предварительной обработки информации.


Data pre-processing


Первое, что бросается в глаза, это формат данных нашего биржевого датасета.



Кроме того, что данные приведены в текстовом формате, выгрузка совсем некстати содержит запятые и разнородные ‘K’, и ‘M’ в значениях объемов, что не дает конвертировать данные во float-формат стандартными методами. Дотошное гугление рыночных данных с пристрастием не дало результатов такого же широкого временного диапазона. Что ж. Регулярные выражения Python, дайте мне силы!



Код конвертера str во float
import re
import pandas as pd

def strtofloatconvert(data):

    #Converting series to list
    price_lst = data['Price'].to_list()
    open_lst = data['Open'].to_list()
    high_lst = data['High'].to_list()
    low_lst = data['Low'].to_list()
    vol_lst = data['Vol.'].to_list()
    change_lst = data['Change %'].to_list()

    #Separating str by ',' sign exept Volume strings. It's got anoter
    #format, like '294.8K' or '12.9M'. Volume we convert lower
    sprt_prices = []
    sprt_open = []
    sprt_high = []
    sprt_low = []
    sprt_p = []
    sprt_o = []
    sprt_h = []
    sprt_l = []

    for price in price_lst:
        sprt_p = re.split(r',',price)
        sprt_prices.append(sprt_p)
    for open_p in open_lst:
        sprt_o = re.split(r',',open_p)
        sprt_open.append(sprt_o)
    for high in high_lst:
        sprt_h = re.split(r',',high)
        sprt_high.append(sprt_h)
    for low in low_lst:
        sprt_l = re.split(r',',low)
        sprt_low.append(sprt_l)

    #Adding splitted values together and converting them to float
    add_p = []
    add_o = []
    add_h = []
    add_l = []
    add_v = []
    add_ch = []

    for p in sprt_prices:
        if len(p) == 2:
            a = p[0]+p[1]
            a = float(a)
            add_p.append(a)
        else:
            a = p[0]
            a = float(a)
            add_p.append(a)
    for o in sprt_open:
        if len(o) == 2:
            a = o[0]+o[1]
            a = float(a)
            add_o.append(a)
        else:
            a = o[0]
            a = float(a)
            add_o.append(a)
    for h in sprt_high:
        if len(h) == 2:
            a = h[0]+h[1]
            a = float(a)
            add_h.append(a)
        else:
            a = h[0]
            a = float(a)
            add_h.append(a)
    for l in sprt_low:
        if len(l) == 2:
            a = l[0]+l[1]
            a = float(a)
            add_l.append(a)
        else:
            a = l[0]
            a = float(a)
            add_l.append(a)

    #Working with zeroes in 'Vol.' in str_flt because it does not matter whether we put None or zero in empty space.
    #Pandas will convert None into NaN wich is kind of float 'number'. It will not respond to pandas 'isnull()' function.
    for v in vol_lst:
        if v == '-':
            add_v.append(0)
        else:
            exam = re.findall(r'K',v)
            if len(exam)>0:
                add = re.sub(r'K', '',v)
                add = float(add)
                add *= 1000
                add_v.append(add)
            else:
                add = re.sub(r'M', '',v)
                add = float(add)
                add *= 1000000
                add_v.append(add)
    for i in change_lst:
        add = re.sub(r'%', '',i)
        add = float(add)
        add_ch.append(add)

    #Putting all lists above to the DataFrame
    test_df = pd.DataFrame(data=(add_p, add_o, add_h, add_l,
                                        add_v,add_ch)).T
    test_df.columns = ['Price', 'Open', 'High', 'Low', 'Vol.', 'Change%']
    return test_df

Другое дело.


Посмотрим на хвост полученного нами датафрейма.



267 элементов датасета не содержат информации о динамике изменения стоимости. Удалим их повышения общей информативности данных ради.


Теперь, поскольку мы собрались все же прогнозировать поведение цены в будущем, нам нужно организовать данные так, чтобы цена в момент времени Т соответствовала набору признаков T-n, где n — это дальность нашего горизонта прогноза. Предлагаю попытаться заглянуть в будущее для начала на один день вперед. Для этого мы удаляем последнее значение из перечня цен закрытия и первую строку соответствующих признаков.


price = test_df.pop('Price')
price = price.drop(price.index[-1])
test_df = test_df.drop(test_df.index[0])

test_df.index = np.arange(len(test_df))
test_df = pd.concat((price,test_df), axis=1)

Таким образом мы получаем имитацию реальной картины мира. Текущие цены будут прогнозироваться на основе вчерашних данных, а данные за сегодняшний день, соответственно, будут лежать в основе прогноза на день завтрашний.


Поскольку в процессе тестирования нашей нейросети тренировочная и тестовая выборки будут состоять из перемешанных значений подготовленного нами датасета, а нам с вами все-таки хочется посмотреть, как прогнозы будут соотноситься с реальными котировками, ради наглядности отщипнем от данных первые 45 строк из расчета тестирования результатов на промежутке длинной в полтора месяца.


PRACTICE_DS_SIZE = 45
later_testds_for_plot = test_df.iloc[:PRACTICE_DS_SIZE]
test_df = test_df.iloc[len(later_testds_for_plot):]
test_df = test_df.reset_index(drop=True)

Для предстоящей оценки эффективности модели, тренированной только на биржевых данных, пока что извлечем из датасета информацию об объемах выходов транзакций. Так же, за ненадобностью в процессе обучения и прогнозирования, извлечем даты.


timestamps = test_df.pop('Date')
sum_outs = test_df.pop('out_sums')

Делим датасет на тренировочную и тестовую выборки и извлекаем зависимую переменную — цену закрытия:


# Making train/test split
train = test_df.sample(frac=0.8, random_state=42)
test = test_df.drop(train.index)

#Setting targets
train_labels = train.pop('Price')
test_labels = test.pop('Price')

Полученные данные нормализуем.


def norm(train_data, data):
    #Getting overall statistics
    train_stats = train_data.describe()
    train_stats = train_stats.transpose()

    #Normalising data
    normalized_data = (data - train_stats['mean']) / train_stats['std']

    return normalized_data

Теперь проверим насколько эффектным будет обычный многослойный перцептрон, если ему скормить только лишь биржевые данные.


Тестирование нейронной сети


Покрутив некоторое время гиперпараметры, я получил следующую, наиболее оптимальную из всех изученных мной, конфигурацию нейросети:


Код конфигурации нейронной сети
def build_model():
    model = keras.Sequential([
    layers.Dense(32, activation='relu', input_shape=[len(train.keys())]),
    layers.Dense(32, activation='relu'),
    layers.Dense(32, activation='relu'),
    layers.Dense(16, activation='relu'),
    layers.Dense(1)
    ])
    optimizer = tf.keras.optimizers.RMSprop(0.001)
    model.compile(loss='mse',
                optimizer=optimizer,
                metrics=['mae', 'mse'])
    return model
model = build_model()
model.summary()

EPOCHS = 500

# Patience parameter describes epoch amount testing on improvement
early_stop = keras.callbacks.EarlyStopping(monitor='val_loss', patience=10)

model.fit(normed_train_data2, train_labels2, epochs=EPOCHS,
                    validation_split = 0.2, verbose=0, callbacks=[early_stop, PrintDot()])


PrintDot() — коллбэк отвечающий за вывод красивых точек в процессе обучения модели. Позаимствовал его из официальной документации по TensorFlow. Очень уж он мне понравился. Выглядит он незамысловато:


class PrintDot2(keras.callbacks.Callback):
    def on_epoch_end(self, epoch, logs):
        if epoch % 100 == 0: print('')
        print('.', end='')

Задача его — рисовать точки, отражая эпохи.
Что ж, запустим тренировку нашей модели и посмотрим на результат.



Проверим эффективность модели на тестовой выборке.


loss, mae, mse = model.evaluate(normed_test_data, test_labels, verbose=2)

Результат: 506/506 — 0s — loss: 36201.9241 — mae: 66.5216 — mse: 36201.9219


MSE в 45430.1133 долларов оставляет нас со среднеквадратическим отклонением в 190.27 долларов. Многовато.


Теперь проведем обучение и тестирование сети на данных, содержащих выходы транзакций.



loss, mae, mse = model.evaluate(normed_test_data2, test_labels2, verbose=2)

Результат: 506/506 — 0s — loss: 24382.0926 — mae: 48.5508 — mse: 24382.0918


MSE в 24382.0918 долларов значит, что среднеквадратическое отклонение сократилось до 156.15 долларов, что, конечно, не сказка, но улучшение налицо.


Момент истины. Произведем прогнозирование и нарисуем как соотносятся предсказанные значения цен закрытия с реальными ценами.


Код для формирования графиков
actual_price = later_testds_for_plot.pop('Price')
actual_dates = later_testds_for_plot.pop('Date')
normed_practice_data = norm(train, later_testds_for_plot)
practice_prediction = model.predict(normed_practice_data).flatten()

actual_price2 = later_testds_for_plot2.pop('Price')
actual_dates2 = later_testds_for_plot2.pop('Date')
normed_practice_data2 = norm(train2, later_testds_for_plot2)
practice_prediction2 = model.predict(normed_practice_data2).flatten()

fig = plt.figure(figsize=(15,6))
ax1 = fig.add_subplot(121)
ax2 = fig.add_subplot(122)

ax1.plot(actual_dates,actual_price, label ='actual btc price')
ax1.plot(actual_dates,practice_prediction, label ='predicted btc price')
ax1.set_title('BTC Close Price Prediction with only Exch Data')
ax1.legend()

ax2.plot(actual_dates2,actual_price2, label ='actual btc price')
ax2.plot(actual_dates2,practice_prediction2, label ='predicted btc price')
ax2.set_title('BTC Close Price Prediction with Outs')
ax2.legend()


На графиках видно, что линия цен, спрогнозированных с учетом выходов транзакций сети биткоин, лежит значительно ближе к линии действительных цен закрытия. Этого, безусловно, недостаточно для того, чтобы торговать, но достаточно, чтобы набраться оптимизма в отношении последующих исследований.


Выводы


Итак, если Годзилла и живет внутри водоема, то уж точно не в области динамики объемов выходов транзакций сети биткоин. Тем не менее, определенных успехов в части прогнозной эффективности, благодаря добавлению дополнительных источников в модель данных, мы добились.


Этот вольный эксперимент, безусловно, не обнадеживает в части создания финансового «Демона Лапласа» (мы ж ведь все-таки разумные люди), но дарит определенный оптимизм в отношении моделирования хотя бы небольшого такого «бесенка».


Ресурсы


Tags:криптовалютыпредсказание ценпрогноз котировокнейросетигодзилла
Hubs: Machine learning Research and forecasts in IT Cryptocurrencies
Total votes 18: ↑18 and ↓0 +18
Views9.5K
Machine Learning Engineer - NLP
from 1,500 to 2,500 €InsideDNARemote job
Senior Python Backend Developer (Machine Learning)
from 250,000 to 350,000 ₽FunCorpМосква
IT Recruiter
from 50,000 ₽Red LabRemote job
Рекрутер (IT)
from 30,000 to 70,000 ₽ЛАНИТУфа
Senior Software Developer (KTM project)
from 230,000 to 250,000 ₽KOFAXСанкт-ПетербургRemote job

Top of the last 24 hours