📈 Искусственный интеллект для фондового рынка

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

Применяемые в торговле для прогнозирования модели машинного обучения используют не только исторические цены на акции. Значимую информацию можно извлечь из отчётов по форме 10-К – годовых сводок финансовых результатов, подаваемых компаниями США в комиссию по ценным бумагам и биржам. Анализ отчёта и рыночного отклика на его публикацию помогают инвесторам сделать выводы о необходимости вложения средств в развитие компании. Ускорить процесс обработки данных позволяют технологии обработки естественного языка.

Обработка естественного языка

Схематическое представление конвейера обработки естественного языка

Обработка естественного языка (Natural Language Processing, NLP) – раздел когнитивных технологий, занимающийся обучением компьютеров чтению и извлечению смысла из различных форм речи. Чтобы понять текст, программа должна пройти ряд этапов.

  1. Сегментация предложений. Текстовый документ разбивается на отдельные предложения.
  2. Токенизация. Предложения разделяются на отдельные слова, которые называются токенами.
  3. Частеречная разметка. Каждый токен и несколько слов вокруг него вводятся в предварительно обученную модель классификации, чтобы получить на выходе часть речи.
  4. Лемматизация. Слова часто появляются в разных формах и контекстах. Чтобы компьютер мог выявлять разные формы одного слова, выполняется лемматизация – процесс группировки различных словоизменений для анализа их как единого элемента (идентифицируемого леммой слова).
  5. Стоп-слова. Распространенные части речи, такие как предлоги «и», «в», «на», не несут смысловой нагрузки, поэтому они идентифицируются как стоп-слова и исключаются из анализа текста.
  6. Анализ зависимостей. Назначение синтаксической структуры предложениям и выяснение, как слова в предложении соотносятся друг с другом.
  7. Субстантивные словосочетания. Группировка словосочетаний для упрощения предложений в тех случаях, когда мы не заботимся о прилагательных.
  8. Распознавание именованных сущностей. Модель распознавания именованных сущностей может помечать такие объекты, как имена людей, названия компаний и топонимы.
  9. Разрешение кореферентности. Поскольку модели 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-разработчикам, желающим окунуться в мир возвратов и оборотов.

***

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
11 декабря 2018

ООП на Python: концепции, принципы и примеры реализации

Программирование на Python допускает различные методологии, но в его основе...
admin
14 июля 2017

Пишем свою нейросеть: пошаговое руководство

Отличный гайд про нейросеть от теории к практике. Вы узнаете из каких элеме...
admin
13 февраля 2017

Программирование на Python: от новичка до профессионала

Пошаговая инструкция для всех, кто хочет изучить программирование на Python...