📈 Искусственный интеллект для фондового рынка
Изучаем на практических примерах, как методы машинного обучения помогают анализировать финансовые отчёты и покупать акции.
Применяемые в торговле для прогнозирования модели
машинного обучения используют не только исторические цены на акции. Значимую информацию можно извлечь из отчётов по форме 10-К – годовых сводок финансовых результатов, подаваемых компаниями США в комиссию по ценным бумагам и биржам . Анализ отчёта и рыночного отклика на его публикацию помогают инвесторам сделать выводы о необходимости вложения средств в развитие компании. Ускорить процесс обработки данных позволяют технологии обработки естественного языка .
Обработка естественного языка
Схематическое представление конвейера обработки естественного языка
Обработка естественного
языка (Natural Language Processing, NLP) – раздел когнитивных технологий, занимающийся обучением компьютеров
чтению и извлечению смысла из различных форм речи. Чтобы понять текст, программа должна пройти ряд этапов.
Сегментация предложений . Текстовый документ разбивается на отдельные предложения.
Токенизация . Предложения разделяются на отдельные слова, которые называются токенами.
Частеречная разметка . Каждый токен и несколько слов вокруг него вводятся в предварительно обученную модель классификации, чтобы получить на выходе часть речи.
Лемматизация . Слова часто появляются в разных формах и контекстах. Чтобы компьютер мог выявлять разные формы одного слова, выполняется лемматизация – процесс группировки различных словоизменений для анализа их как единого элемента (идентифицируемого леммой слова).
Стоп-слова . Распространенные части речи, такие как предлоги «и», «в», «на», не несут смысловой нагрузки, поэтому они идентифицируются как стоп-слова и исключаются из анализа текста.
Анализ зависимостей . Назначение синтаксической структуры предложениям и выяснение, как слова в предложении соотносятся друг с другом.
Субстантивные словосочетания . Группировка словосочетаний для упрощения предложений в тех случаях, когда мы не заботимся о прилагательных.
Распознавание именованных сущностей . Модель распознавания именованных сущностей может помечать такие объекты, как имена людей, названия компаний и топонимы.
Разрешение кореферентности . Поскольку модели NLP анализируют отдельные предложения, они «теряются» в местоимениях, относящихся к существительным из других предложений. Чтобы решить эту проблему, используется отслеживающее местоимения в разных предложениях разрешение кореферентности.
Импортируем всё необходимое
Для реализации проекта нам нужно будет использовать Pandas , Numpy и пакет для обработки естественного языка nltk
. Попутно будут встречаться и другие библиотеки. Все они легко подключаются через инструмент pip
.
# сторонние библиотеки
import nltk
import numpy as np
import pandas as pd
from tqdm import tqdm
# внутренние библиотеки
import pickle
import pprint
# модуль, описанный ниже
import project_helper
Также нам пригодятся специально написанные модули project_helper
(нужно будет установить библиотеку ratelimit
) и project_tests
. Модуль project_helper
содержит служебные и графические функции, project_tests
– модульные тесты.
project_helper.py
import matplotlib.pyplot as plt
import requests
from ratelimit import limits, sleep_and_retry # сторонняя библиотека
class SecAPI(object):
SEC_CALL_LIMIT = {'calls': 10, 'seconds': 1}
@staticmethod
@sleep_and_retry
# Dividing the call limit by half to avoid coming close to the limit
@limits(calls=SEC_CALL_LIMIT['calls'] / 2, period=SEC_CALL_LIMIT['seconds'])
def _call_sec(url):
return requests.get(url)
def get(self, url):
return self._call_sec(url).text
def print_ten_k_data(ten_k_data, fields, field_length_limit=50):
indentation = ' '
print('[')
for ten_k in ten_k_data:
print_statement = '{}{{'.format(indentation)
for field in fields:
value = str(ten_k[field])
# Show return lines in output
if isinstance(value, str):
value_str = '\'{}\''.format(value.replace('\n', '\\n'))
else:
value_str = str(value)
# Cut off the string if it gets too long
if len(value_str) > field_length_limit:
value_str = value_str[:field_length_limit] + '...'
print_statement += '\n{}{}: {}'.format(indentation * 2, field, value_str)
print_statement += '},'
print(print_statement)
print(']')
def plot_similarities(similarities_list, dates, title, labels):
assert len(similarities_list) == len(labels)
plt.figure(1, figsize=(10, 7))
for similarities, label in zip(similarities_list, labels):
plt.title(title)
plt.plot(dates, similarities, label=label)
plt.legend()
plt.xticks(rotation=90)
plt.show()
project_tests.py
import numpy as np
import pandas as pd
from collections import OrderedDict
from tests import assert_output, project_test, assert_structure
@project_test
def test_get_documents(fn):
# Test 1
doc = '\nThis is inside the document\n' \
'This is the text that should be copied'
text = 'This is before the test document<DOCUMENT>{}</DOCUMENT>\n' \
'This is after the document\n' \
'This shouldn\t be included.'.format(doc)
fn_inputs = {
'text': text}
fn_correct_outputs = OrderedDict([
(
'extracted_docs', [doc])])
assert_output(fn, fn_inputs, fn_correct_outputs, check_parameter_changes=False)
# Test 2
ten_k_real_compressed_doc = '\n' \
'<TYPE>10-K\n' \
'<SEQUENCE>1\n' \
'<FILENAME>test-20171231x10k.htm\n' \
'<DESCRIPTION>10-K\n' \
'<TEXT>\n' \
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">\n' \
'<html>\n' \
' <head>\n' \
' <title>Document</title>\n' \
' </head>\n' \
' <body style="font-family:Times New Roman;font-size:10pt;">\n' \
'...\n' \
'<td><strong> Data Type:</strong></td>\n' \
'<td>xbrli:sharesItemType</td>\n' \
'</tr>\n' \
'<tr>\n' \
'<td><strong> Balance Type:</strong></td>\n' \
'<td>na</td>\n' \
'</tr>\n' \
'<tr>\n' \
'<td><strong> Period Type:</strong></td>\n' \
'<td>duration</td>\n' \
'</tr>\n' \
'</table></div>\n' \
'</div></td></tr>\n' \
'</table>\n' \
'</div>\n' \
'</body>\n' \
'</html>\n' \
'</TEXT>\n'
excel_real_compressed_doc = '\n' \
'<TYPE>EXCEL\n' \
'<SEQUENCE>106\n' \
'<FILENAME>Financial_Report.xlsx\n' \
'<DESCRIPTION>IDEA: XBRL DOCUMENT\n' \
'<TEXT>\n' \
'begin 644 Financial_Report.xlsx\n' \
'M4$L#!!0 ( %"E04P?(\\#P !," + 7W)E;,O+G)E;.MDD^+\n' \
'MPD ,Q;]*F?L:5\#8CUYZ6U9_ )Q)OU#.Y,A$[%^>X>];+=44/ 87O+>CT?V\n' \
'...\n' \
'M,C,Q7V1E9BYX;6Q02P$"% ,4 " !0I4%,>V7[]F0L 0!(@A %0\n' \
'M @ %N9@, 86UZ;BTR,#$W,3(S,5]L86(N>&UL4$L! A0#% @\n' \
'M4*5!3*U*Q:W#O0 U=\) !4 ( !!9,$ &%M>FXM,C Q-S$R\n' \
'@,S%?<)E+GAM;%!+!08 !@ & (H! #[4 4 !\n' \
'\n' \
'end\n' \
'</TEXT>\n'
real_compressed_text = '<SEC-DOCUMENT>0002014754-18-050402.txt : 20180202\n' \
'<SEC-HEADER>00002014754-18-050402.hdr.sgml : 20180202\n' \
'<ACCEPTANCE-DATETIME>20180201204115\n' \
'ACCESSION NUMBER: 0002014754-18-050402\n' \
'CONFORMED SUBMISSION TYPE: 10-K\n' \
'PUBLIC DOCUMENT COUNT: 110\n' \
'CONFORMED PERIOD OF REPORT: 20171231\n' \
'FILED AS OF DATE: 20180202\n' \
'DATE AS OF CHANGE: 20180201\n' \
'\n' \
'FILER:\n' \
'\n' \
' COMPANY DATA: \n' \
' COMPANY CONFORMED NAME: TEST\n' \
' CENTRAL INDEX KEY: 0001018724\n' \
' STANDARD INDUSTRIAL CLASSIFICATION: RANDOM [2357234]\n' \
' IRS NUMBER: 91236464620\n' \
' STATE OF INCORPORATION: DE\n' \
' FISCAL YEAR END: 1231\n' \
'\n' \
' FILING VALUES:\n' \
' FORM TYPE: 10-K\n' \
' SEC ACT: 1934 Act\n' \
' SEC FILE NUMBER: 000-2225413\n' \
' FILM NUMBER: 13822526583969\n' \
'\n' \
' BUSINESS ADDRESS: \n' \
' STREET 1: 422320 PLACE AVENUE\n' \
' CITY: SEATTLE\n' \
' STATE: WA\n' \
' ZIP: 234234\n' \
' BUSINESS PHONE: 306234534246600\n' \
'\n' \
' MAIL ADDRESS: \n' \
' STREET 1: 422320 PLACE AVENUE\n' \
' CITY: SEATTLE\n' \
' STATE: WA\n' \
' ZIP: 234234\n' \
'</SEC-HEADER>\n' \
'<DOCUMENT>{}</DOCUMENT>\n' \
'<DOCUMENT>{}</DOCUMENT>\n' \
'</SEC-DOCUMENT>\n'.format(ten_k_real_compressed_doc, excel_real_compressed_doc)
fn_inputs = {
'text': real_compressed_text}
fn_correct_outputs = OrderedDict([
(
'extracted_docs', [ten_k_real_compressed_doc, excel_real_compressed_doc])])
assert_output(fn, fn_inputs, fn_correct_outputs, check_parameter_changes=False)
@project_test
def test_get_document_type(fn):
doc = '\n' \
'<TYPE>10-K\n' \
'<SEQUENCE>1\n' \
'<FILENAME>test-20171231x10k.htm\n' \
'<DESCRIPTION>10-K\n' \
'<TEXT>\n' \
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">\n' \
'...'
fn_inputs = {
'doc': doc}
fn_correct_outputs = OrderedDict([
(
'doc_type', '10-k')])
assert_output(fn, fn_inputs, fn_correct_outputs, check_parameter_changes=False)
@project_test
def test_lemmatize_words(fn):
fn_inputs = {
'words': ['cow', 'running', 'jeep', 'swimmers', 'tackle', 'throw', 'driven']}
fn_correct_outputs = OrderedDict([
(
'lemmatized_words', ['cow', 'run', 'jeep', 'swimmers', 'tackle', 'throw', 'drive'])])
assert_output(fn, fn_inputs, fn_correct_outputs, check_parameter_changes=False)
@project_test
def test_get_bag_of_words(fn):
def sort_ndarray(array):
hashes = [hash(str(x)) for x in array]
sotred_indicies = sorted(range(len(hashes)), key=lambda k: hashes[k])
return array[sotred_indicies]
fn_inputs = {
'sentiment_words': pd.Series(['one', 'last', 'second']),
'docs': [
'this is a document',
'this document is the second document',
'last one']}
fn_correct_outputs = OrderedDict([
(
'bag_of_words', np.array([
[0, 0, 0],
[1, 0, 0],
[0, 1, 1]]))])
fn_out = fn(**fn_inputs)
assert_structure(fn_out, fn_correct_outputs['bag_of_words'], 'bag_of_words')
assert np.array_equal(sort_ndarray(fn_out.T), sort_ndarray(fn_correct_outputs['bag_of_words'].T)), \
'Wrong value for bag_of_words.\n' \
'INPUT docs:\n{}\n\n' \
'OUTPUT bag_of_words:\n{}\n\n' \
'A POSSIBLE CORRECT OUTPUT FOR bag_of_words:\n{}\n'\
.format(fn_inputs['docs'], fn_out, fn_correct_outputs['bag_of_words'])
@project_test
def test_get_jaccard_similarity(fn):
fn_inputs = {
'bag_of_words_matrix': np.array([
[0, 1, 1, 0, 0, 0, 1],
[0, 1, 2, 0, 1, 1, 1],
[1, 0, 0, 1, 0, 0, 0]])}
fn_correct_outputs = OrderedDict([
(
'jaccard_similarities', [0.7142857142857143, 0.0])])
assert_output(fn, fn_inputs, fn_correct_outputs, check_parameter_changes=False)
@project_test
def test_get_tfidf(fn):
def sort_ndarray(array):
hashes = [hash(str(x)) for x in array]
sotred_indicies = sorted(range(len(hashes)), key=lambda k: hashes[k])
return array[sotred_indicies]
fn_inputs = {
'sentiment_words': pd.Series(['one', 'last', 'second']),
'docs': [
'this is a document',
'this document is the second document',
'last one']}
fn_correct_outputs = OrderedDict([
(
'tfidf', np.array([
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 0.70710678, 0.70710678]]))])
fn_out = fn(**fn_inputs)
assert_structure(fn_out, fn_correct_outputs['tfidf'], 'tfidf')
assert np.isclose(sort_ndarray(fn_out.T), sort_ndarray(fn_correct_outputs['tfidf'].T)).all(), \
'Wrong value for tfidf.\n' \
'INPUT docs:\n{}\n\n' \
'OUTPUT tfidf:\n{}\n\n' \
'A POSSIBLE CORRECT OUTPUT FOR tfidf:\n{}\n'\
.format(fn_inputs['docs'], fn_out, fn_correct_outputs['tfidf'])
@project_test
def test_get_cosine_similarity(fn):
fn_inputs = {
'tfidf_matrix': np.array([
[0.0, 0.57735027, 0.57735027, 0.0, 0.0, 0.0, 0.57735027],
[0.0, 0.32516555, 0.6503311, 0.0, 0.42755362, 0.42755362, 0.32516555],
[0.70710678, 0.0, 0.0, 0.70710678, 0.0, 0.0, 0.0]])}
fn_correct_outputs = OrderedDict([
(
'cosine_similarities', [0.75093766927060945, 0.0])])
assert_output(fn, fn_inputs, fn_correct_outputs, check_parameter_changes=False)
Затем мы загружаем
стоп-слова для их удаления и wordnet для лемматизации.
nltk.download('stopwords')
nltk.download('wordnet')
Всю дальнейшую обработку будем производить в Jupyter Notebook (GitHub-репозиторий ).
Получение 10-К
Отчёты 10-K включают описание истории компании, организационной структуры,
вознаграждений руководителей, собственного капитала, капитала дочерних компаний и аудиты финансовой отчётности. Для поиска документов можно использовать уникальный для
каждой компании CIK (Central Index Key). Перечислим их примеры в виде словаря:
cik_lookup = {
'AMZN': '0001018724',
'BMY': '0000014272',
'CNP': '0001130310',
'CVX': '0000093410',
'FL': '0000850209',
'FRT': '0000034903',
'HON': '0000773840'}
Выведем для примера данные Amazon. Подключение к сайту, где хранятся отчёты , производится с помощью упомянутого выше модуля project_helper
:
sec_api = project_helper.SecAPI()
from bs4 import BeautifulSoup
def get_sec_data(cik, doc_type, start=0, count=60):
rss_url = 'https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany' \
'&CIK={}&type={}&start={}&count={}&owner=exclude&output=atom' \
.format(cik, doc_type, start, count)
sec_data = sec_api.get(rss_url)
feed = BeautifulSoup(sec_data.encode('ascii'), 'xml').feed
entries = [
( entry.content.find('filing-href').getText(),
entry.content.find('filing-type').getText(),
entry.content.find('filing-date').getText())
for entry in feed.find_all('entry', recursive=False)]
return entries
example_ticker = 'AMZN'
sec_data = {}
for ticker, cik in cik_lookup.items():
sec_data[ticker] = get_sec_data(cik, '10-K')
pprint.pprint(sec_data[example_ticker][:5])
В результате получаем следующий вывод:
Результат выполнения программного блока
[('https://www.sec.gov/Archives/edgar/data/1018724/000101872420000004/0001018724-20-000004-index.htm',
'10-K',
'2020-01-31'),
('https://www.sec.gov/Archives/edgar/data/1018724/000101872419000004/0001018724-19-000004-index.htm',
'10-K',
'2019-02-01'),
('https://www.sec.gov/Archives/edgar/data/1018724/000101872418000005/0001018724-18-000005-index.htm',
'10-K',
'2018-02-02'),
('https://www.sec.gov/Archives/edgar/data/1018724/000101872417000011/0001018724-17-000011-index.htm',
'10-K',
'2017-02-10'),
('https://www.sec.gov/Archives/edgar/data/1018724/000101872416000172/0001018724-16-000172-index.htm',
'10-K',
'2016-01-29')]
Теперь у нас есть список URL-адресов, указывающих на файлы с метаданными, остаётся только забрать информацию:
raw_fillings_by_ticker = {}
for ticker, data in sec_data.items():
raw_fillings_by_ticker[ticker] = {}
for index_url, file_type, file_date in tqdm(data, desc='Downloading {} Fillings'.format(ticker), unit='filling'):
if (file_type == '10-K'):
file_url = index_url.replace('-index.htm', '.txt').replace('.txtl', '.txt')
raw_fillings_by_ticker[ticker][file_date] = sec_api.get(file_url)
print('Example Document:\n\n{}...'.format(next(iter(raw_fillings_by_ticker[example_ticker].values()))[:1000]))
Процесс загрузки данных, содержащих отчеты (отображение прогресс-бара осуществляется с помощью tqdm)
Downloading AMZN Fillings: 100%|██████████| 25/25 [00:05<00:00, 4.88filling/s]
Downloading BMY Fillings: 100%|██████████| 30/30 [00:12<00:00, 2.49filling/s]
Downloading CNP Fillings: 100%|██████████| 22/22 [00:57<00:00, 2.61s/filling]
Downloading CVX Fillings: 100%|██████████| 28/28 [00:34<00:00, 1.24s/filling]
Downloading FL Fillings: 100%|██████████| 25/25 [00:25<00:00, 1.02s/filling]
Downloading FRT Fillings: 100%|██████████| 32/32 [00:36<00:00, 1.15s/filling]
Downloading HON Fillings: 29%|██▊ | 8/28 [00:10<00:25, 1.30s/filling]
Первая 1000 символов примера загруженного отчёта
Example Document:
<SEC-DOCUMENT>0001018724-20-000004.txt : 20200131
<SEC-HEADER>0001018724-20-000004.hdr.sgml : 20200131
<ACCEPTANCE-DATETIME>20200130204613
ACCESSION NUMBER: 0001018724-20-000004
CONFORMED SUBMISSION TYPE: 10-K
PUBLIC DOCUMENT COUNT: 109
CONFORMED PERIOD OF REPORT: 20191231
FILED AS OF DATE: 20200131
DATE AS OF CHANGE: 20200130
FILER:
COMPANY DATA:
COMPANY CONFORMED NAME: AMAZON COM INC
CENTRAL INDEX KEY: 0001018724
STANDARD INDUSTRIAL CLASSIFICATION: RETAIL-CATALOG & MAIL-ORDER HOUSES [5961]
IRS NUMBER: 911646860
STATE OF INCORPORATION: DE
FISCAL YEAR END: 1231
FILING VALUES:
FORM TYPE: 10-K
SEC ACT: 1934 Act
SEC FILE NUMBER: 000-22513
FILM NUMBER: 20562951
BUSINESS ADDRESS:
STREET 1: 410 TERRY AVENUE NORTH
CITY: SEATTLE
STATE: WA
ZIP: 98109
BUSINESS PHONE: 2062661000
MAIL ADDRESS:
STREET 1: 410 TERRY AVENUE NORTH
CITY: SEATTLE
STATE: WA
ZIP: 98109
</SEC-HEADER>
<DOCUMENT>
<TYPE>10-K
<SEQUENCE>1
<FILENAM...
Разобьем загруженные
файлы на документы по ограничивающим тегам <DOCUMENT>
и </DOCUMENT>
:
import re
def get_documents(text):
extracted_docs = []
doc_start_pattern = re.compile(r'<DOCUMENT>')
doc_end_pattern = re.compile(r'</DOCUMENT>')
doc_start_is = [x.end() for x in doc_start_pattern.finditer(text)]
doc_end_is = [x.start() for x in doc_end_pattern.finditer(text)]
for doc_start_i, doc_end_i in zip(doc_start_is, doc_end_is):
extracted_docs.append(text[doc_start_i:doc_end_i])
return extracted_docs
filling_documents_by_ticker = {}
for ticker, raw_fillings in raw_fillings_by_ticker.items():
filling_documents_by_ticker[ticker] = {}
for file_date, filling in tqdm(raw_fillings.items(), desc='Getting Documents from {} Fillings'.format(ticker), unit='filling'):
filling_documents_by_ticker[ticker][file_date] = get_documents(filling)
print('\n\n'.join([
'Document {} Filed on {}:\n{}...'.format(doc_i, file_date, doc[:200])
for file_date, docs in filling_documents_by_ticker[example_ticker].items()
for doc_i, doc in enumerate(docs)][:3]))
Процесс преобразования документов
Getting Documents from AMZN Fillings: 100%|██████████| 20/20 [00:00<00:00, 71.03filling/s]
Getting Documents from BMY Fillings: 100%|██████████| 26/26 [00:00<00:00, 42.15filling/s]
Getting Documents from CNP Fillings: 100%|██████████| 18/18 [00:00<00:00, 33.56filling/s]
Getting Documents from CVX Fillings: 100%|██████████| 24/24 [00:00<00:00, 36.84filling/s]
Getting Documents from FL Fillings: 100%|██████████| 19/19 [00:00<00:00, 58.05filling/s]
Getting Documents from FRT Fillings: 100%|██████████| 22/22 [00:00<00:00, 58.46filling/s]
Getting Documents from HON Fillings: 100%|██████████| 23/23 [00:00<00:00, 46.45filling/s]
Результаты разбиения
Document 0 Filed on 2020-01-31:
<TYPE>10-K
<SEQUENCE>1
<FILENAME>amzn-20191231x10k.htm
<DESCRIPTION>10-K
<TEXT>
<XBRL>
<?xml version="1.0" encoding="UTF-8"?>
<!--XBRL Document Created with Wdesk from Workiva-->
<!--p:c57a17684e854b...
Document 1 Filed on 2020-01-31:
<TYPE>EX-4.6
<SEQUENCE>2
<FILENAME>amzn-20191231xex46.htm
<DESCRIPTION>EXHIBIT 4.6
<TEXT>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>...
Document 2 Filed on 2020-01-31:
<TYPE>EX-21.1
<SEQUENCE>3
<FILENAME>amzn-20191231xex211.htm
<DESCRIPTION>EXHIBIT 21.1
<TEXT>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<ht...
Определим функцию
get_document_type()
для возврата типа документа:
def get_document_type(doc):
type_pattern = re.compile(r'<TYPE>[^\n]+')
doc_type = type_pattern.findall(doc)[0][len('<TYPE>'):]
return doc_type.lower()
Отфильтруем с помощью написанной функции все не являющиеся отчетами документы из вложений:
ten_ks_by_ticker = {}
for ticker, filling_documents in filling_documents_by_ticker.items():
ten_ks_by_ticker[ticker] = []
for file_date, documents in filling_documents.items():
for document in documents:
if get_document_type(document) == '10-k':
ten_ks_by_ticker[ticker].append({
'cik': cik_lookup[ticker],
'file': document,
'file_date': file_date})
project_helper.print_ten_k_data(ten_ks_by_ticker[example_ticker][:5], ['cik', 'file', 'file_date'])
[
{
cik: '0001018724'
file: '\n<TYPE>10-K\n<SEQUENCE>1\n<FILENAME>amzn-2019123...
file_date: '2020-01-31'},
{
cik: '0001018724'
file: '\n<TYPE>10-K\n<SEQUENCE>1\n<FILENAME>amzn-2018123...
file_date: '2019-02-01'},
{
cik: '0001018724'
file: '\n<TYPE>10-K\n<SEQUENCE>1\n<FILENAME>amzn-2017123...
file_date: '2018-02-02'},
{
cik: '0001018724'
file: '\n<TYPE>10-K\n<SEQUENCE>1\n<FILENAME>amzn-2016123...
file_date: '2017-02-10'},
{
cik: '0001018724'
file: '\n<TYPE>10-K\n<SEQUENCE>1\n<FILENAME>amzn-2015123...
file_date: '2016-01-29'},
]
Предобработка данных
Удалим html-код и переведем все символы в строчные:
def remove_html_tags(text):
text = BeautifulSoup(text, 'html.parser').get_text()
return text
def clean_text(text):
text = text.lower()
text = remove_html_tags(text)
return text
Очистим документы с
помощью функции clean_text()
:
for ticker, ten_ks in ten_ks_by_ticker.items():
for ten_k in tqdm(ten_ks, desc='Cleaning {} 10-Ks'.format(ticker), unit='10-K'):
ten_k['file_clean'] = clean_text(ten_k['file'])
project_helper.print_ten_k_data(ten_ks_by_ticker[example_ticker][:5], ['file_clean'])
Cleaning AMZN 10-Ks: 100%|██████████| 20/20 [00:26<00:00, 1.31s/10-K]
Cleaning BMY 10-Ks: 100%|██████████| 26/26 [00:56<00:00, 2.17s/10-K]
Cleaning CNP 10-Ks: 100%|██████████| 18/18 [00:54<00:00, 3.02s/10-K]
Cleaning CVX 10-Ks: 100%|██████████| 24/24 [01:44<00:00, 4.33s/10-K]
Cleaning FL 10-Ks: 100%|██████████| 19/19 [00:21<00:00, 1.14s/10-K]
Cleaning FRT 10-Ks: 100%|██████████| 22/22 [00:43<00:00, 1.97s/10-K]
Cleaning HON 10-Ks: 100%|██████████| 23/23 [00:46<00:00, 2.02s/10-K]
[
{
file_clean: '\n10-k\n1\namzn-20191231x10k.htm\n10-k\n\n\n\n\n\...},
{
file_clean: '\n10-k\n1\namzn-20181231x10k.htm\n10-k\n\n\n\n\n\...},
{
file_clean: '\n10-k\n1\namzn-20171231x10k.htm\n10-k\n\n\n\n\n\...},
{
file_clean: '\n10-k\n1\namzn-20161231x10k.htm\nform 10-k\n\n\n...},
{
file_clean: '\n10-k\n1\namzn-20151231x10k.htm\nform 10-k\n\n\n...},
]
Лемматизируем данные:
from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet
def lemmatize_words(words):
lemmatized_words = [WordNetLemmatizer().lemmatize(word, 'v') for word in words]
return lemmatized_words
word_pattern = re.compile('\w+')
for ticker, ten_ks in ten_ks_by_ticker.items():
for ten_k in tqdm(ten_ks, desc='Lemmatize {} 10-Ks'.format(ticker), unit='10-K'):
ten_k['file_lemma'] = lemmatize_words(word_pattern.findall(ten_k['file_clean']))
project_helper.print_ten_k_data(ten_ks_by_ticker[example_ticker][:5], ['file_lemma'])
Вывод
Lemmatize AMZN 10-Ks: 100%|██████████| 20/20 [00:03<00:00, 5.4210-K/s]
Lemmatize BMY 10-Ks: 100%|██████████| 26/26 [00:04<00:00, 5.3810-K/s]
Lemmatize CNP 10-Ks: 100%|██████████| 18/18 [00:04<00:00, 3.9610-K/s]
Lemmatize CVX 10-Ks: 100%|██████████| 24/24 [00:04<00:00, 5.0810-K/s]
Lemmatize FL 10-Ks: 100%|██████████| 19/19 [00:02<00:00, 9.2110-K/s]
Lemmatize FRT 10-Ks: 100%|██████████| 22/22 [00:03<00:00, 7.0410-K/s]
Lemmatize HON 10-Ks: 100%|██████████| 23/23 [00:03<00:00, 7.6110-K/s]
[
{
file_lemma: '['10', 'k', '1', 'amzn', '20191231x10k', 'htm', '...},
{
file_lemma: '['10', 'k', '1', 'amzn', '20181231x10k', 'htm', '...},
{
file_lemma: '['10', 'k', '1', 'amzn', '20171231x10k', 'htm', '...},
{
file_lemma: '['10', 'k', '1', 'amzn', '20161231x10k', 'htm', '...},
{
file_lemma: '['10', 'k', '1', 'amzn', '20151231x10k', 'htm', '...},
]
Удаляем стоп-слова:
from nltk.corpus import stopwords
lemma_english_stopwords = lemmatize_words(stopwords.words('english'))
for ticker, ten_ks in ten_ks_by_ticker.items():
for ten_k in tqdm(ten_ks, desc='Remove Stop Words for {} 10-Ks'.format(ticker), unit='10-K'):
ten_k['file_lemma'] = [word for word in ten_k['file_lemma'] if word not in lemma_english_stopwords]
print('Stop Words Removed')
Remove Stop Words for AMZN 10-Ks: 100%|██████████| 20/20 [00:01<00:00, 15.8810-K/s]
Remove Stop Words for BMY 10-Ks: 100%|██████████| 26/26 [00:02<00:00, 9.8610-K/s]
Remove Stop Words for CNP 10-Ks: 100%|██████████| 18/18 [00:02<00:00, 7.5410-K/s]
Remove Stop Words for CVX 10-Ks: 100%|██████████| 24/24 [00:02<00:00, 9.4010-K/s]
Remove Stop Words for FL 10-Ks: 100%|██████████| 19/19 [00:01<00:00, 17.5510-K/s]
Remove Stop Words for FRT 10-Ks: 100%|██████████| 22/22 [00:01<00:00, 13.2810-K/s]
Remove Stop Words for HON 10-Ks: 100%|██████████| 23/23 [00:01<00:00, 15.1510-K/s]
Stop Words Removed
Анализ тональностей
10-К
Воспользуемся списком слов Loughran-McDonald для выполнения анализа тональностей текстов в 10-К. Он был
специально разработан для текстового анализа с финансовой составляющей.
sentiments = ['negative', 'positive', 'uncertainty', 'litigious', 'constraining']
sentiment_df = pd.read_csv('loughran_mcdonald_master_dic_2018.csv')
sentiment_df.columns = [column.lower() for column in sentiment_df.columns]
# Удалим неиспользующуюся информацию
sentiment_df = sentiment_df[sentiments + ['word']]
sentiment_df[sentiments] = sentiment_df[sentiments].astype(bool)
sentiment_df = sentiment_df[(sentiment_df[sentiments]).any(1)]
# Применим ту же предобработку данных, что для отчетов 10-K
sentiment_df['word'] = lemmatize_words(sentiment_df['word'].str.lower())
sentiment_df = sentiment_df.drop_duplicates('word')
sentiment_df.head()
Первые пять строк подготовленного датасета
Создадим набор тональностей
слов для документов 10-К, используя списки тональностей. Подсчитаем количество слов с определённой тональностью в каждом документе:
from collections import defaultdict, Counter
from sklearn.feature_extraction.text import CountVectorizer
def get_bag_of_words(sentiment_words, docs):
vec = CountVectorizer(vocabulary=sentiment_words)
vectors = vec.fit_transform(docs)
words_list = vec.get_feature_names()
bag_of_words = np.zeros([len(docs), len(words_list)])
for i in range(len(docs)):
bag_of_words[i] = vectors[i].toarray()[0]
return bag_of_words.astype(int)
sentiment_bow_ten_ks = {}
for ticker, ten_ks in ten_ks_by_ticker.items():
lemma_docs = [' '.join(ten_k['file_lemma']) for ten_k in ten_ks]
sentiment_bow_ten_ks[ticker] = {
sentiment: get_bag_of_words(sentiment_df[sentiment_df[sentiment]]['word'], lemma_docs)
for sentiment in sentiments}
project_helper.print_ten_k_data([sentiment_bow_ten_ks[example_ticker]], sentiments)
[
{
negative: '[[0 0 0 ... 0 0 0]\n [0 0 0 ... 0 0 0]\n [0 0 0 ....
positive: '[[12 0 0 ... 0 0 0]\n [15 0 0 ... 0 0 0...
uncertainty: '[[0 0 0 ... 1 1 2]\n [0 0 0 ... 1 1 2]\n [0 0 0 ....
litigious: '[[0 0 0 ... 0 0 0]\n [0 0 0 ... 0 0 0]\n [0 0 0 ....
constraining: '[[0 0 0 ... 0 0 2]\n [0 0 0 ... 0 0 2]\n [0 0 0 ....},
]
Сходство Жаккара
Когда у нас
есть набор слов, мы можем преобразовать его в логический массив и вычислить коэффициент сходства Жаккара . Сходство определяется как размер пересечения множеств, поделенный на размер их объединения.
Например, сходство Жаккара между двумя предложениями
– это количество общих слов в двух предложениях, поделенное на общее количество
уникальных слов в обоих предложениях. Чем ближе значение сходства к 1, тем
более похожи наборы. Для наглядности построим график коэффициента сходства Жаккара.
from sklearn.metrics import jaccard_similarity_score
def get_jaccard_similarity(bag_of_words_matrix):
jaccard_similarities = []
bag_of_words_matrix = np.array(bag_of_words_matrix, dtype=bool)
for i in range(len(bag_of_words_matrix)-1):
u = bag_of_words_matrix[i]
v = bag_of_words_matrix[i+1]
jaccard_similarities.append(jaccard_similarity_score(u,v))
return jaccard_similarities
# Get dates for the universe
file_dates = {
ticker: [ten_k['file_date'] for ten_k in ten_ks]
for ticker, ten_ks in ten_ks_by_ticker.items()}
jaccard_similarities = {
ticker: {
sentiment_name: get_jaccard_similarity(sentiment_values)
for sentiment_name, sentiment_values in ten_k_sentiments.items()}
for ticker, ten_k_sentiments in sentiment_bow_ten_ks.items()}
project_helper.plot_similarities(
[jaccard_similarities[example_ticker][sentiment] for sentiment in sentiments],
file_dates[example_ticker][1:],
'Jaccard Similarities for {} Sentiment'.format(example_ticker),
sentiments)
TF-IDF
Из списков слов
тональности создадим term frequency – inverse document frequency (TF-IDF) для документов 10-К. TF-IDF – это метод поиска информации, используемый для выявления частоты появления слова в выбранной коллекции текста. Каждому слову/термину
присваивается частота термина (TF) и обратная частота документа (IDF).
Произведение этих оценок называется весом TF-IDF данного термина. Высокий вес
TF-IDF указывает на редкие термины, а низкий – на более общие.
from sklearn.feature_extraction.text import TfidfVectorizer
def get_tfidf(sentiment_words, docs):
vec = TfidfVectorizer(vocabulary=sentiment_words)
tfidf = vec.fit_transform(docs)
return tfidf.toarray()
sentiment_tfidf_ten_ks = {}
for ticker, ten_ks in ten_ks_by_ticker.items():
lemma_docs = [' '.join(ten_k['file_lemma']) for ten_k in ten_ks]
sentiment_tfidf_ten_ks[ticker] = {
sentiment: get_tfidf(sentiment_df[sentiment_df[sentiment]]['word'], lemma_docs)
for sentiment in sentiments}
project_helper.print_ten_k_data([sentiment_tfidf_ten_ks[example_ticker]], sentiments)
Косинусное сходство
Исходя из значений TF-IDF,
мы можем вычислить косинусное сходство и построить его с учетом изменения
времени. Подобно сходству Жаккара – это метрика, используемая для определения схожести
документов. Косинус схожести вычисляет подобие независимо от размера, измеряя
косинус угла между двумя векторами, проецируемыми в многомерном пространстве.
Для текстового анализа обычно используются массивы, содержащие количество слов
в двух документах.
from sklearn.metrics.pairwise import cosine_similarity
def get_cosine_similarity(tfidf_matrix):
cosine_similarities = []
for i in range(len(tfidf_matrix)-1):
cosine_similarities.append(cosine_similarity(tfidf_matrix[i].reshape(1, -1),tfidf_matrix[i+1].reshape(1, -1))[0,0])
return cosine_similarities
cosine_similarities = {
ticker: {
sentiment_name: get_cosine_similarity(sentiment_values)
for sentiment_name, sentiment_values in ten_k_sentiments.items()}
for ticker, ten_k_sentiments in sentiment_tfidf_ten_ks.items()}
project_helper.plot_similarities(
[cosine_similarities[example_ticker][sentiment] for sentiment in sentiments],
file_dates[example_ticker][1:],
'Cosine Similarities for {} Sentiment'.format(example_ticker),
sentiments)
Ценовые данные
Теперь мы оценим альфа-факторы,
сравнив их с годовыми ценами на акции. Мы можем скачать данные о ценах из
QuoteMedia.
pricing = pd.read_csv('yr-quotemedia.csv', parse_dates=['date'])
pricing = pricing.pivot(index='date', columns='ticker', values='adj_close')
pricing
Преобразование в
датафрейм
Alphalens – использующая датафреймы библиотека Python для анализа производительности альфа-факторов поможет преобразовать наш словарь.
cosine_similarities_df_dict = {'date': [], 'ticker': [], 'sentiment': [], 'value': []}
for ticker, ten_k_sentiments in cosine_similarities.items():
for sentiment_name, sentiment_values in ten_k_sentiments.items():
for sentiment_values, sentiment_value in enumerate(sentiment_values):
cosine_similarities_df_dict['ticker'].append(ticker)
cosine_similarities_df_dict['sentiment'].append(sentiment_name)
cosine_similarities_df_dict['value'].append(sentiment_value)
cosine_similarities_df_dict['date'].append(file_dates[ticker][1:][sentiment_values])
cosine_similarities_df = pd.DataFrame(cosine_similarities_df_dict)
cosine_similarities_df['date'] = pd.DatetimeIndex(cosine_similarities_df['date']).year
cosine_similarities_df['date'] = pd.to_datetime(cosine_similarities_df['date'], format='%Y')
cosine_similarities_df.head()
Перед использованием функции alphalens
нужно выровнять индексы и преобразовать
время в unix timestamp :
import alphalens as al
factor_data = {}
skipped_sentiments = []
for sentiment in sentiments:
cs_df = cosine_similarities_df[(cosine_similarities_df['sentiment'] == sentiment)]
cs_df = cs_df.pivot(index='date', columns='ticker', values='value')
try:
data = al.utils.get_clean_factor_and_forward_returns(cs_df.stack(), pricing.loc[cs_df.index], quantiles=5, bins=None, periods=[1])
factor_data[sentiment] = data
except:
skipped_sentiments.append(sentiment)
if skipped_sentiments:
print('\nSkipped the following sentiments:\n{}'.format('\n'.join(skipped_sentiments)))
factor_data[sentiments[0]].head()
Мы также должны создать
факторные датафреймы с unix-временем для совместимости с alphalen-функциями:
factor_rank_autocorrelation
и mean_return_by_quantile
:
unixt_factor_data = {
factor: data.set_index(pd.MultiIndex.from_tuples(
[(x.timestamp(), y) for x, y in data.index.values],
names=['date', 'asset']))
for factor, data in factor_data.items()}
Коэффициент отдачи
Посмотрим на
коэффициент отдачи с течением времени:
ls_factor_returns = pd.DataFrame()
for factor_name, data in factor_data.items():
ls_factor_returns[factor_name] = al.performance.factor_returns(data).iloc[:, 0]
(1 + ls_factor_returns).cumprod().plot()
Выражающие позитивную тональность отчеты 10-К свидетельствуют о наибольшей прибыли,
а негативные – об убытках.
Анализ оборота
Используя factor_rank_autocorrelation
,
мы можем проанализировать, насколько устойчивы альфы с течением времени. Мы хотим, чтобы они оставались относительно одинаковыми от периода к периоду:
ls_FRA = pd.DataFrame()
for factor, data in unixt_factor_data.items():
ls_FRA[factor] = al.performance.factor_rank_autocorrelation(data)
ls_FRA.plot(title="Factor Rank Autocorrelation")
Коэффициент Шарпа
Теперь давайте
рассчитаем коэффициент Шарпа, который представляет собой разность между средней доходностью и безрисковой доходностью, поделённой на стандартное отклонение доходности
инвестиций.
daily_annualization_factor = np.sqrt(252)
(daily_annualization_factor * ls_factor_returns.mean() / ls_factor_returns.std()).round(2)
Коэффициент Шарпа 1
считается приемлемым, 2 – очень хорошим, а 3 – превосходным. Положительная тональность коррелирует с высоким коэффициентом Шарпа, а
отрицательная – с низким. Другие настроения также коррелировали с высокими
коэффициентами.
Заключение
В статье мы
рассмотрели занимательную тему обработки естественного языка. Этот подход применяется на фондовых рынках, поскольку дает массу информации
для понимания процессов и помогает инвесторам принять решение о вложении средств. Тема непростая, но она будет интересна не только работающим в финансовой сфере специалистам, но и Python -разработчикам, желающим окунуться в мир возвратов и
оборотов.
***
Источники