Евгений Шибаев
Евгений Шибаев личный блог
07 декабря 2020, 22:54

Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.

Всем здоровья и бодрого расположения духа!
В статье «Визуализация рекомендаций Романа Андреева на Python» мы разобрали как можно с помощью нескольких строк кода на Питоне разобрать текст, который выкладывает каждое утро в своем блоге Роман Андреев (далее по тексту Роман) — известный трейдер и блогер (или наоборот), и отобразить эти рекомендации в виде уровней и зон на графиках. В этом топике я покажу способ для извлечения информации из графических изображений с помощью технологий компьютерного зрения (но без использования нейронных сетей) на примере таблиц-рекомендаций из блога Романа Андреева.
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.
Надеюсь, что я не напугал читателей термином «компьютер вижн», скоро вы поймете, что это просто. И что любой юный прогер может написать код для распознавания внешними камерами номеров автомобилей, который впоследствии возненавидят все автолюбители мегаполисов, а МАДИ и ГИБДД будут собирать со всех нас миллиардные штрафы для поддержания собственных иерархий для улучшения безопасности движения. Се ля ви...
Нам понадобятся следующие ингредиенты:
1. Быть другом Романа Андреева (в хорошем смысле слова) на Смартлабе, чтобы иметь доступ к его блогу.
2. Любая среда Питон (я использовал Jupyter Notebook Anaconda 3)
3. Установленный в Питоне пакет OpenCV — библиотека алгоритмов компьютерного зрения и обработки изображений с открытым кодом. Установка заняла несколько секунд командой $pip install opencv-python. Маленький лайфхак, про который постараюсь подробно рассказать в будущих топиках. Если у вас нет Питона, воспользуйтесь халявой на Google Colab. Там вам предоставят бесплатный Питон в браузере, уже установленные библиотеки, в том числе OpenCV, халявные вычисления на GPU для нейросетей и много других плюшек. Взамен потребуют, как обычно — всего лишь вашу душу:)
4. Набор шаблонов (графических изображений цифр, слов и знаков) для распознавания (ссылка). Папка называется RomanAndreev и содержит в себе две папки. Папка ImageSamples — в ней собраны шаблоны — png-файлы слов, цифр и знаков для распознавания, будем называть их токенами. Я заранее подготовил шаблоны, затратив на это время, сопоставимое с временем написание кода. Выглядит так:
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.
Папка Data. В этой папке собраны графические файлы с рекомендациями (примерно с конца сентября, исключая дни, когда Роман лакомился муксуном и нельмой в нефтедобывающих краях). И в нее вы будете копировать новые рекомендации (правая кнопка мыши на табличке — «Сохранить картинку как»), из которых мы будем извлекать информацию для каждого тикера.

Итак, исходные данные в сборе, переходим к решению.

Сама по себе задача поиска текста на изображениях не является тривиальной, особенно если нужно понять смысл текста (семантику). Но в нашем случае все оказывается гораздо проще. Сходу приведу кусок кода, который распознАет цифру 8(восемь) в файле рекомендации от 1 декабря 2020 года и отметит ее местоположение на изображении.

import cv2 as cv
import numpy as np

template = cv.imread('RomanAndreev\\ImageSamples\\8.png', 0) #читаем шаблоц цифры "8"
img_rgb = cv.imread('RomanAndreev\\Data\\2020-12-01.png') #Читаем изображение рекомендации от 1 декабря 2020 года
img_gray = cv.cvtColor(img_rgb, cv.COLOR_BGR2GRAY) #Делаем изображение черно-белым в оттенках серого
img_gray = cv.GaussianBlur(img_gray, (3, 3), 0) #Слегка размываем изображение с помощью фильтра Гаусса
w, h = template.shape[::-1] #Определяем ширину и высоту шаблона
res = cv.matchTemplate(img_gray,template,cv.TM_CCOEFF_NORMED) #Проходим шаблоном по изображению в поисках совпадений
threshold = 0.8 #Задаем порог, при превышении которого шаблон считается найденным на изображении
loc = np.where( res >= threshold) #Таких мест на изображении может быть много, записываем их координаты в матрицу
#Рисуем на основном изображении рамочки, где мы нашли шаблон
for pt in zip(*loc[::-1]):
    cv.rectangle(img_rgb, pt, (pt[0] + w, pt[1] + h), (0,0,255), 1)
cv.imwrite('res8.png',img_rgb) #Записываем результат в файл
После выполнения кода (CTRL+Enter) в файл res8.png будет выглядеть так:
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.

Так вот как камеры определяют номера автовладельцев-нарушителей(но нам бояться нечего — мы все ездим по правилам)!
Понятно, что распознавание не идеально, и как мы видим, за восьмерку были приняты заглавные буквы B-латинская и В-кириллица. От восьмерки еще отличить их можно, но между собой — никак...
Ничего страшного, вспомним классику. В х/ф «Особенности национальной рыбалки» между двумя персонажами состоялся такой диалог: — Весной ящик коньяка со сходен уронили. Когда день рождения начальника тыла праздновали. Так они в мути у военного пирса его отыскали и подняли. — А там глубоко? — Глубоко. — Достанут! — Очень глубоко. — Все равно достанут. Когда знают чего и сколько утопили — достанут!
Вот так и мы, в этой задаче знаем, чего и сколько искать. Для этого выделим по контурам названия инструментов (тикеров), ключевые слова «лонг» и «шорт», а также цифры от 0 до 9 и запятую, и сохраним их в качестве шаблонов — назовем их токенами. Они лежат в папке ImageSamples.
Принцип распознавания следующий: сначала мы находим изображение тикера (например 'Ri-') на основном изображении. Затем движемся вправо по горизонтали, чтобы найти токены «лонг» или «шорт», далее снова движемся вправо в поиске цифр и запятых, если очередная цифра расположена на расстоянии от предыдущей, значит в этом месте начинается новое число.
В коде, который представлен ниже основная функция StartRunning('2020-12-07'), принимает строку в виде ГГГГ-ММ-ДД, соответствующую дате рекомендации и имени png-файла изображения с таблицей 2020-12-07.png, который вы сохранили в папку Data. Фактически функция распознает то, что находится на изображении и сохраняет эту информацию уже в формате csv, в файле ГГГГ-ММ-ДД.csv. Затем читает этот файл в датафрейм Pandas и возвращает его для дальнейшей обработки.

Код с подробными комментариями:

#©2020 by Evgeny Shibaev. А пользуются теперь НУ ВООБЩЕ ВСЕ!

#Для установки OpenCV воспользуйтесь командой $ pip install opencv-python
#Код для распознавания на графическом изображении чисел, слов и знаков препинания по образцам. Для примера взяты
#изображения рекомендаций по различным финансовым инструментам из блога Романа Андреева на Смартлабе.
#В результате выполнения кода мы преобразуем картинку в дата фрейм, соответствующий содержанию рекомендации.
import cv2 as cv
import numpy as np
import pandas as pd
import csv
import os.path
from matplotlib import pyplot as plt

#Список инструментов (тикеров)
ticker_names = ['BR-','GD-','RI-','Si-','MICEX','Eu-','VTBR','GAZP','GMKN','LKOH','MTSS','ROSN','RTKM','SBER','SBERP',
                'CHMF','SNGS','SNGSP','FEES']

#tokens - список имен png-файлов, содержащих изображения символов и слов, которые нужно найти на изображении (без учета
#имен тикеров):['0','1','2','3','4','5','6','7','8','9','comma','long','short']. Файлы хранятся в папке \\ImageSamples\\
tokens = [str(digit) for digit in range(10)] + ['comma', 'long', 'short'] #Templates file names

#folder = папка в Питоне, содержащая папку Data с .png-файлами рекомендаций и папку ImageSamples с образцами цифр,
#знаков и слов для распознавания.
folder = 'RomanAndreev'

# locations это словарь, ключами которого будут имена токенов, а значениями список координат, где эти
# токены были выявлены на основном изображении
locations = {}

#Порог, при превышении которого образец считается найденным на основном изображении. С порогом можно "поиграть",
#если какой-то шаблон не идентифицтруется. Стандартно порог=0.8 В нашем случае эмпирически найдено 0.789.
#На десяти тестовых файлах не было ни одной ошибки при распознавании
threshold = 0.789


#Основная функция для запуска. Извлекает из изображения данные, сохраняет их в формате csv и возвращаетв датафрейм
# date - строка вида '2020-11-30', дата в формате ГГГГ-ММ-ДД. Соответствует имени файла изображения в папке Data
def StartRunning (date):
    #Файл куда вы сохранили картинку с рекомендациями по тикерам
    image_file = f'{folder}\\Data\\{date}.png'
    
    #Проверяем не забыли ли вы сохранить изображение
    if not os.path.exists(image_file):
        print(f'No such file: {image_file}')
        return False

    #Читаем .png файл из которого будем извлекать информацию
    img_rgb = cv.imread(image_file)
    
    #Делаем изображение черно-белым в оттенках серого - засеряем (наверное от слова "засеря")
    img_gray = cv.cvtColor(img_rgb, cv.COLOR_BGR2GRAY)
    
    #Определяем координаты нужных нам цифр, знаков препинания и слов на изображении, т.е. заполняем словарь locations
    GetTokenLocations(img_gray)
    
    #По каждому тикеру записываем строку с рекомендациями в файл 'ГГГГ-ММ-ДД.csv' 
    file = f'{folder}\\Data\\{date}.csv'
    with open(file, mode='w', encoding='utf-8') as f:
        #Для записи информации в CSV файл создаем объект writer
        fw = csv.writer(f) #для просмотра в EXCEL добавьте delimiter = ';', lineterminator='\r'
        fw.writerow(['Date', 'Tiker', 'Posa', 'Open', 'Reverse', 'PL'])
        #По каждому тикеру записываем строку с рекомендациями в файл 'ГГГГ-ММ-ДД.csv' 
        for ticker in ticker_names:
            fw.writerow(GetTikerInfo (date, ticker, img_gray))

    #Для проверки, что все чики-пуки, зачтём данные из csv файла в датафрейм Пандас df     
    df = pd.read_csv(file, index_col = 'Tiker')  
#    print(df)
    return df

#Функция определяет координаты найденных токенов на изображении image и сохраняет их в locations
def GetTokenLocations (image):
    #Для каждого токена
    for token in tokens:
        #Читаем из файла образец изображения токена в template
        template = cv.imread(f'{folder}\\ImageSamples\\{token}.png',0)
        #Собственно "волшебство" распознавания: матрица, содержащая степень похожести образца с куском основного
        #изображения. Используем небольшое "Гауссово размытие" - изображение становится как будто вы забыли надеть очки
        res = cv.matchTemplate(cv.GaussianBlur(image, (3, 3), 0),template,cv.TM_CCOEFF_NORMED)
        #Сохраняем в словаре, для каждого токена координаты точек, где совпадение превышает заданный порог
        locations[token] = np.where(res >= threshold)

#Функция для определения рекомендации по тикеру, возвращает список типа: ['2020-11-30', 'MICEX', 'long', '2758', '3112']
def GetTikerInfo (date, tiker_name, img_gray):
    #Читаем в template изображение тикера из файла
    template = cv.imread(f'{folder}\\ImageSamples\\{tiker_name}.png',0)
    w, h = template.shape[::-1]
    res = cv.matchTemplate(cv.GaussianBlur(img_gray, (3, 3), 0),template,cv.TM_CCOEFF_NORMED)
    #Определяем координаты наилучших совпадений с образцом
    loc = np.where(res >= threshold)
    #Находим координату Y, чтобы значение было не только выше порога но и максимальным.
    #Таким образом мы привязали Y к строке, в которой расположена информация о тикере
    Y = loc[0][np.argmax(res[loc], axis = 0)]
    #Одному и тому же изображению могут соответствовать несколько соседних точек, особенно если шрифт жирный, 
    #мы впоследствии оставим только одну из них, но сейчас сделаем окрестность dY из ближайших точек
    dY = (Y, Y+1, Y-1)
    #token_pos - словарь, ключами которого являются токены, а значениями -  списки координат, соответствующих этим токенам
    #У одного токена может быть несколько координат, например для значения 48,88 у токена '4'и ',' - будет по одной 
    #координате, а токена '8' - три.
    token_pos = {}
    rightx, posa = 0, 0 #Инициализация переменных. Описание далее.
    #Для каждого токена определяем его позицию в окрестностях координаты Y (в строке) и оставляем только одну
    for token in tokens: #loop only for tokens        
        for pt in zip(*locations[token][::-1]):
            if pt[1] in dY:
                #Для координаты Х также создаем окрестность и проверяем, чтобы одному токену соответствовала только одна
                #координата Х
                dX = (pt[0], pt[0]+1, pt[0]-1)
                #Этим выражением исключаем "лишние"значения координат Х для одного токена
                if not (any(map (lambda key: key in dX, token_pos))):
                    #Если мы нашли изображения слов 'шорт' или 'лонг', то запоминаем позицию в posa, а также точку (rightx)
                    #от которой будем "искать" вправо собственно цену открытия позиции и ее реверса.
                    if token in ('short','long'):
                        posa = token
                        rightx = pt[0]
                    token_pos[pt[0]]= token
    #После окончания цикла инициализируем строку значением ticker и posa через пробел (она содержит позицию по тикеру)
    price_str = date + ' ' + tiker_name + ' ' + posa
    #indent - задает расстояние в пикселях между токенами (буквами или цифрами), в случае превышения которого 
    #мы считаем что, следующий найденный токен будет началом нового слова (или числа)
    indent = 30 #pixels
    oldx = rightx
    #Сортируем найденные в строке токены по возрастанию координаты Х, не включая те токены, которые находились левее
    #столбца "Позиция" на изображении. Т.к. слева могут попадаться цифры в наименовании тикера, например Ri-12.20
    for item in [i for i in sorted(token_pos.items()) if i[0] > rightx]:
        if item[0] > oldx + indent:
            price_str = price_str + ' ' + item[1]
        else:
            price_str = price_str + item[1]
        oldx = item[0]
    #После окончания цикла строка price_str будет примерно такой "short 47comma53 49comma1". Заменим в ней 'comma' 
    #на '.', затем разделяем на пробелы получим список с рекомендацией типа:['short', '47.53', '49.1']
    return price_str.replace('comma', '.').split()

#Если вы нашли ошибку при распознавании, например пропущена какая-то цифра (что было редко) или цифра распозналась 
#неправильно (такого не было), то есть два способа исправить. Первый простой - откройте блокнотом csv-файл в папке Data и 
#исправьте вручную. Второй - более сложный, но позволит избежать подобной ошибки в будущем: откройте основное изображение
# графическим редактором, выделите на основном изображении ту цифру, которая была пропущена, например 8, обрежьте
#изображение до размеров остальных шаблонов и сохраните в файл под именем '8.png' в папку ImageSamples поверх
#старого шаблона. Затем перезапустите основную функцию и проверьте результат распознавания

res=StartRunning('2020-12-07')
res

Для чего я сделал промежуточное сохранение в csv формат? Очень редко, но встречаются ошибки при распознавании, возможно из-за того, что порог распознавания treshold оказался чуть выше найденного значения и токен не попадет в список (будет просто пропущен). В этом случае откройте файл ГГГГ-ММ-ДД.csv обычным блокнотом, вставьте пропущенную цифру и сохраните изменения. Вторая причина — все мы люди, и Роман может ошибиться (нет, не в направлении движения рынка) — просто по невнимательности. В этом случае, любая не стыковка сразу будет видна на графиках. Я приведу пример ошибочно указанного направления для Eu-12.20 в конце статьи.

После того, как мы извлекли нужную нам информацию из изображения, ее нужно представить графически. Данные для отображения графиков мы берем с сайта Финама. Сам код детально описан в предыдущем топике. В него лишь добавлены тикеры 'Si-','RI-','Eu-','GD-' и 'BR-' и их коды для декабрьских контрактов (нефть — январь). При переходе на новые контракты исправьте коды тикеров, зайдя на сайт Финама. Имена тикеров должны соответствовать именам в переменной ticker_names.

from urllib.parse import urlencode
from urllib.request import urlopen
from datetime import datetime, timedelta
import pandas as pd

FINAM_URL = "http://export.finam.ru/" # сервер, на который стучимся
#каждому таймфрейму на Финаме соответствует цифровой код:
periods={'tick': 1, 'min': 2, '5min': 3, '10min': 4, '15min': 5, '30min': 6, 'hour': 7, 'daily': 8, 'week': 9, 'month': 10}
#каждому символу Финам присвоил цифровой код:
symbols={'Si-':502420,'RI-':502418,'Eu-':893255,'GD-':924737,'BR-':926134,'S&P':13944,'USDRUB':901,'ED':83,'GD':18953,'MICEX':420450,'BZ':19473,'ABRD':82460,'AESL':181867,'AFKS':19715,'AFLT':29,'AGRO':399716,'AKRN':17564,'ALBK':82616,'ALNU':81882,'ALRS':81820,'AMEZ':20702,'APTK':13855,'AQUA':35238,'ARMD':19676,'ARSA':19915,'ASSB':16452,'AVAN':82843,'AVAZ':39,'AVAZP':40,'BANE':81757,'BANEP':81758,'BGDE':175840,'BISV':35242,'BISVP':35243,'BLNG':21078,'BRZL':81901,'BSPB':20066,'CBOM':420694,'CHEP':20999,'CHGZ':81933,'CHKZ':21000,'CHMF':16136,'CHMK':21001,'CHZN':19960,'CLSB':16712,'CLSBP':16713,'CNTL':21002,'CNTLP':81575,'DASB':16825,'DGBZ':17919,'DIOD':35363,'DIXY':18564,'DVEC':19724,'DZRD':74744,'DZRDP':74745,'ELTZ':81934,'ENRU':16440,'EPLN':451471,'ERCO':81935,'FEES':20509,'FESH':20708,'FORTP':82164,'GAZA':81997,'GAZAP':81998,'GAZC':81398,'GAZP':16842,'GAZS':81399,'GAZT':82115,'GCHE':20125,'GMKN':795,'GRAZ':16610,'GRNT':449114,'GTLC':152876,'GTPR':175842,'GTSS':436120,'HALS':17698,'HIMC':81939,'HIMCP':81940,'HYDR':20266,'IDJT':388276,'IDVP':409486,'IGST':81885,'IGST03':81886,'IGSTP':81887,'IRAO':20516,'IRGZ':9,'IRKT':15547,'ISKJ':17137,'JNOS':15722,'JNOSP':15723,'KAZT':81941,'KAZTP':81942,'KBSB':19916,'KBTK':35285,'KCHE':20030,'KCHEP':20498,'KGKC':83261,'KGKCP':152350,'KLSB':16329,'KMAZ':15544,'KMEZ':22525,'KMTZ':81903,'KOGK':20710,'KRKN':81891,'KRKNP':81892,'KRKO':81905,'KRKOP':81906,'KROT':510,'KROTP':511,'KRSB':20912,'KRSBP':20913,'KRSG':15518,'KSGR':75094,'KTSB':16284,'KTSBP':16285,'KUBE':522,'KUNF':81943,'KUZB':83165,'KZMS':17359,'KZOS':81856,'KZOSP':81857,'LIFE':74584,'LKOH':8,'LNTA':385792,'LNZL':21004,'LNZLP':22094,'LPSB':16276,'LSNG':31,'LSNGP':542,'LSRG':19736,'LVHK':152517,'MAGE':74562,'MAGEP':74563,'MAGN':16782,'MERF':20947,'MFGS':30,'MFGSP':51,'MFON':152516,'MGNT':17086,'MGNZ':20892,'MGTS':12984,'MGTSP':12983,'MGVM':81829,'MISB':16330,'MISBP':16331,'MNFD':80390,'MOBB':82890,'MOEX':152798,'MORI':81944,'MOTZ':21116,'MRKC':20235,'MRKK':20412,'MRKP':20107,'MRKS':20346,'MRKU':20402,'MRKV':20286,'MRKY':20681,'MRKZ':20309,'MRSB':16359,'MSNG':6,'MSRS':16917,'MSST':152676,'MSTT':74549,'MTLR':21018,'MTLRP':80745,'MTSS':15523,'MUGS':81945,'MUGSP':81946,'MVID':19737,'NAUK':81992,'NFAZ':81287,'NKHP':450432,'NKNC':20100,'NKNCP':20101,'NKSH':81947,'NLMK':17046,'NMTP':19629,'NNSB':16615,'NNSBP':16616,'NPOF':81858,'NSVZ':81929,'NVTK':17370,'ODVA':20737,'OFCB':80728,'OGKB':18684,'OMSH':22891,'OMZZP':15844,'OPIN':20711,'OSMP':21006,'OTCP':407627,'PAZA':81896,'PHOR':81114,'PHST':19717,'PIKK':18654,'PLSM':81241,'PLZL':17123,'PMSB':16908,'PMSBP':16909,'POLY':175924,'PRFN':83121,'PRIM':17850,'PRIN':22806,'PRMB':80818,'PRTK':35247,'PSBR':152320,'QIWI':181610,'RASP':17713,'RBCM':74779,'RDRB':181755,'RGSS':181934,'RKKE':20321,'RLMN':152677,'RLMNP':388313,'RNAV':66644,'RODNP':66693,'ROLO':181316,'ROSB':16866,'ROSN':17273,'ROST':20637,'RSTI':20971,'RSTIP':20972,'RTGZ':152397,'RTKM':7,'RTKMP':15,'RTSB':16783,'RTSBP':16784,'RUAL':414279,'RUALR':74718,'RUGR':66893,'RUSI':81786,'RUSP':20712,'RZSB':16455,'SAGO':445,'SAGOP':70,'SARE':11,'SAREP':24,'SBER':3,'SBERP':23,'SELG':81360,'SELGP':82610,'SELL':21166,'SIBG':436091,'SIBN':2,'SKYC':83122,'SNGS':4,'SNGSP':13,'STSB':20087,'STSBP':20088,'SVAV':16080,'SYNG':19651,'SZPR':22401,'TAER':80593,'TANL':81914,'TANLP':81915,'TASB':16265,'TASBP':16266,'TATN':825,'TATNP':826,'TGKA':18382,'TGKB':17597,'TGKBP':18189,'TGKD':18310,'TGKDP':18391,'TGKN':18176,'TGKO':81899,'TNSE':420644,'TORS':16797,'TORSP':16798,'TRCN':74561,'TRMK':18441,'TRNFP':1012,'TTLK':18371,'TUCH':74746,'TUZA':20716,'UCSS':175781,'UKUZ':20717,'UNAC':22843,'UNKL':82493,'UPRO':18584,'URFD':75124,'URKA':19623,'URKZ':82611,'USBN':81953,'UTAR':15522,'UTII':81040,'UTSY':419504,'UWGN':414560,'VDSB':16352,'VGSB':16456,'VGSBP':16457,'VJGZ':81954,'VJGZP':81955,'VLHZ':17257,'VRAO':20958,'VRAOP':20959,'VRSB':16546,'VRSBP':16547,'VSMO':15965,'VSYD':83251,'VSYDP':83252,'VTBR':19043,'VTGK':19632,'VTRS':82886,'VZRZ':17068,'VZRZP':17067,'WTCM':19095,'WTCMP':19096,'YAKG':81917,'YKEN':81766,'YKENP':81769,'YNDX':388383,'YRSB':16342,'YRSBP':16343,'ZHIV':181674,'ZILL':81918,'ZMZN':556,'ZMZNP':603,'ZVEZ':82001}

# Функция запрашивает котировки с сервера экспорта данных Финама по инструменту для заданного таймфрейма за последние 
# period_days дней и возвращает соответствующий датафрейм
def GetCandles (ticker, time_frame, period_days):
    period=periods[time_frame] #Выбор из: 'tick': 1, 'min': 2, '5min': 3, '10min': 4, '15min': 5, '30min': 6, 'hour': 7, 'daily': 8, 'week': 9, 'month': 10
    market = 0 #91 24 #можно не задавать. Это рынок, на котором торгуется бумага. Для акций работает с любой цифрой. Другие рынки не проверял.
    # Текущий момент времени
    end_date = datetime.today()
    # Время period_days дней назад
    start_date = end_date - timedelta(days = period_days)
    #Все параметры упаковываем в единую структуру. Здесь есть дополнительные параметры, кроме тех, которые заданы в шапке. См. комментарии внизу:
    params = urlencode([
     ('market', market), #на каком рынке торгуется бумага
     ('em', symbols[ticker]), #вытягиваем цифровой символ, который соответствует бумаге.
     ('code', ticker), #тикер нашей акции
     ('df', start_date.day), #Начальная дата, номер дня (1-31)
     ('mf', start_date.month - 1), #Начальная дата, номер месяца (0-11)
     ('yf', start_date.year), #Начальная дата, год
     ('from', start_date), #Начальная дата полностью
     ('dt', end_date.day), #Конечная дата, номер дня
     ('mt', end_date.month - 1), #Конечная дата, номер месяца
     ('yt', end_date.year), #Конечная дата, год
     ('to', end_date), #Конечная дата
     ('p', period), #Таймфрейм
     ('f', ticker), #Имя сформированного файла
     ('e', ".csv"), #Расширение сформированного файла
     ('cn', ticker), #ещё раз тикер акции
     ('dtf', 1), #В каком формате брать даты. Выбор из 5 возможных. См. страницу https://www.finam.ru/profile/moex-akcii/sberbank/export/
     ('MSOR', 0), #Время свечи (0 - open; 1 - close)
     ('mstime', "on"), #Московское время
     ('mstimever', 1), #Коррекция часового пояса
     ('sep', 1), #Разделитель полей (1 - запятая, 2 - точка, 3 - точка с запятой, 4 - табуляция, 5 - пробел)
     ('sep2', 1), #Разделитель разрядов
     ('datf', 1), #Формат записи в файл. Выбор из 6 возможных.
     ('at', 1)]) #Нужны ли заголовки столбцов
    url = FINAM_URL + ticker + ".csv?" + params #собственно URL сформированного запроса
    #Создаем датафрейм candles с котировками
    candles = pd.read_csv(url)
    #Добавляем в датафрейм столбец 'DT', который будет содержать время каждой свечи в формате datetime. 
    #Формируем его из столбцов '<DATE>'и '<TIME>'
    candles['DT'] = list(map(lambda d,t: ToDatetime(d,t), candles['<DATE>'], candles['<TIME>']))
    #Возвращает Датафрейм Пандас со свечами, соответствующими запросу
    return candles

#Преобразует число (или строку) вида 20201030 и строку вида '12:15:00' в объект datetime.datetime(2020, 10, 30, 12, 15)
def ToDatetime (date_num, time_hhmmss):
    return datetime.strptime(str(date_num) + time_hhmmss, '%Y%m%d%H:%M:%S')

#Преобразует строку (или число) вида "20201102" в дату (формат datetime)
def ToDate (date_yyyymmdd):
    return datetime.strptime(str(date_yyyymmdd), '%Y%m%d').date()

#Преобразует строку вида "2020-11-02" в дату (формат datetime)
def ToDateYYYYMMDD (date_yyyy_mm_dd):
    return datetime.strptime(date_yyyy_mm_dd, '%Y-%m-%d').date()

SBER = GetCandles ('SBER', "30min", 10)
SBER
Вы можете, для примера, запустить этот код независимо, в результате получите датафрейм, содержащий 30 минутные свечи акций Сбербанка за последние 10 дней. Этот код мы используем для отображения графиков.
И собственно, код для визуализации. Переменная-словарь tickers содержит названия тикеров для отображения. Если вы не анализируете (с помощью Романа) какие-то тикеры — закомментируйте их — остальные графики отобразятся быстрее. Визуализация запускается функцией start_function() — в конце кода. В начале этой функции определяются переменные, которые можно менять для удобства восприятия. Сейчас они настроены на отображение часового графика за 45 календарных дней и с рекомендациями за последние 30 торговых сессий.
import time
import os
import matplotlib.pyplot as plt

#Список инструментов, которые анализирует Роман. Тикер - токен: тикер это торгуемый актив, а токен - его привычное название.
# Ключ в словаре tickers ДОЛЖЕН СТРОГО СООТВЕТСТВОВАТЬ ключу в словаре symbols (в модуле загрузки котировок с Финама)
tickers = {'RI-' : 'RI-12.20',
           'Si-' : 'Si-12.20',
           'BR-' : 'BR-1.21',
           'GD-' : 'GD-12.20',
           'MICEX':'Индекс ММВБ',
           'Eu-' : 'Eu-12.20',
           'VTBR' : 'ВТБ',
           'GAZP' : 'Газпром',
           'GMKN' : 'ГМК Норильский никель',
           'LKOH' : 'Лукойл'
           'MTSS' : 'MTC',
           'ROSN': 'Роснефть',
           'RTKM': 'Ростелеком',
            'SBER': 'Сбербанк, об',
            'SBERP': 'Сбербанк, пр',
            'CHMF' : 'Северсталь',
           'SNGS' : 'Сургутнефтегаз, об',
           'SNGSP': 'Сургутнефтегаз, пр',
           'FEES' : 'ФСК ЕЭС'
          }

#################################### Помощники для построения графиков ##########################################
#Определяет начальную и конечную позицию Х (по индексу свечей) для заданной даты. Пригодится при отрисовке ценовых уровней
def DateX (date, candles):
    #Цикл по датам в свечах, результат - список X-координат, соответствующих заданной дате
    xpositions = [index for index, row in candles.iterrows() if row['DT'].date() == date]
    #Возвращает список - пару начальная координата Х и конечная координата Х для заданной даты на графике
    if xpositions == []:
        return [len(candles)-1, len(candles)] #На случай если за текущую дату нет еще свечей
    return [xpositions[0], xpositions[-1]]

#Рисует метки дат на оси Х
def PlotDatesX (fig, candles):
    #Составляем список дат (только уникальные даты) из столбца DT. Они будут метками на оси Х. Сортировка по датам
    #обязательна, т.к. при создании множества(set) даже из отсортированного списка, множество может не сохранить порядок списка
    dates = sorted(set(map(lambda dt: datetime.date(dt), candles['DT'])))
    #Создаем список координат Х для каждой метки (даты). Нам нужна только первая позиция - [0].
    xlabel = [DateX(d, candles)[0] for d in dates]
    #Рисуем ось Х, разделенную по датам
    fig.set_xticklabels([dt.strftime("%d %b") for dt in dates])
    fig.set_xticks(xlabel)
    return dates, xlabel

#Рисует основной график
def draw_candles(ticker, candles):
    #Добавим на график несколько ЕМА-средних
    candles['ema100'] = pd.Series.ewm(candles['<CLOSE>'], span=100).mean()
    candles['ema50'] = pd.Series.ewm(candles['<CLOSE>'], span=50).mean()
    candles['ema20'] = pd.Series.ewm(candles['<CLOSE>'], span=20).mean()
    plt.style.use('ggplot') #'seaborn-paper'
    #Отображаем график по цене закрытия свечей и ЕМА-шки
    fig = candles.plot(y=['<CLOSE>', 'ema50', 'ema20', 'ema100'], figsize=(25,16))
    #Добавляем заголовок
    fig.set_title('График ' + tickers[ticker])
    #Рисуем шкалу с датами
    PlotDatesX (fig, candles)

#Если в числе присутствует десятичная точка и после нее нет значащих цифр, делаем из числа целое - "чисто для красоты"
def PointOff(valfloat):
    if valfloat % 1 == 0:
        return int(valfloat)
    return valfloat

#Рисует уровни открытой позиций и уровни переворота позиции на заданную дату, для свечек (candles) тикера. Где Posa -
# текущая позиция 'long' или 'short'на начало дня, price - уровень открытия позиции, reverse - уровень переворота позиции
#Также, отображает зеленым и розовым цветами (с заливкой) уровни предполагаемой прибыли или убытка относительно
#текущей позиции если она будет закрыта (реверсирована)
def draw_levels (datetime_date, candles, posa, price, reverse):
    DX = DateX(datetime_date, candles)
    x = len(candles) #Координата Х правого края графика
    last_price = PointOff(candles.iloc[-1]['<CLOSE>']) #цена последней сделки
    posanum = -1 if posa == 'long' else 1
    equity_color = 'lightgreen' if posanum * (price - reverse) > 0 else 'lightcoral'
    plt.fill([DX[0], DX[0], DX[1], DX[1]], [price, reverse, reverse, price], alpha = 0.2, color = equity_color)
    plt.text(x, last_price, str(last_price), color = 'white', verticalalignment='center', bbox={'facecolor': 'slategray', 'pad': 2})
    posa_color = 'seagreen' if posa == 'long' else 'firebrick'
    plt.plot(DX, [price, price], color = posa_color, linewidth = 3)
    reverse_color = 'red' if posa == 'long' else 'seagreen'
    plt.plot(DX, [reverse, reverse], color = reverse_color, linewidth = 0.5)
    plt.text(DX[0], price, f'{price}', color = 'white', verticalalignment='center', bbox={'facecolor': posa_color, 'pad': 2})
    v_aligment = 'top' if posa == 'long' else 'bottom'
    plt.text(DX[0], reverse, f'{reverse}', color = reverse_color, verticalalignment=v_aligment)
       
def start_function():
    folder = 'RomanAndreev/Data/'
    days_past = 30 #за какое количество торговых сессий от сегодняшней показывать позицию
    days_for_chart = 45 #за какое количество календарных дней строить общий график
    time_frame = 'hour' #какой тайм-фрейм использовать для графика
    daily_df = {} #Словарь, ключами которого будут даты рекомендаций, а значениями датафреймы с самими рекомендациями для каждого тикера на заданную дату
    for file in [f for f in os.listdir('RomanAndreev/Data/') if '.csv' in f][-days_past:]:
        filedate = file.split('.')[0]
        daily_df[filedate] = pd.read_csv(f'RomanAndreev/Data/{file}', index_col = 'Tiker')
        print(file, 'load')

    #Проход по всем тикерам в списке рекомендаций
    print('Please, wait...')
    for ticker in tickers:
        last_levels = [] #в этой переменной после окончания цикла будут уровни текущего дня, которые мы подпишем на графике
        last_advice = '' #а в этой переменной рекомендации на текущий день
        #Читаем в датафрейм candles свечки с сайта Финама
        candles = GetCandles (ticker, time_frame, days_for_chart)
        time.sleep(0.5) #делаем небольшую задержку в запросах к серверу котировок, чтобы нас Финам не забанил
        #Рисуем свечи
        draw_candles(ticker, candles)
        for current_date in daily_df:
            df = daily_df[current_date]
            draw_levels(ToDateYYYYMMDD(current_date), candles, df.loc[ticker]['Posa'], PointOff(df.loc[ticker]['Open']),PointOff(df.loc[ticker]['Reverse']))
    return daily_df    

start_function()
True
Если вы все сделали правильно то в результате получите примерно следующие графики:
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.
Открытая на утро каждого дня позиция отображается как цена на красном фоне для «шорта», на темно-зеленом фоне — для «лонга». Уровни для переворота позы обозначаются без заливки — зеленым для переворота в «лонг», красным — в «шорт». Розовые зоны обозначают предполагаемый убыток, если в этот день позиция будет закрыта (перевернута) на уровне реверса. Светло-зеленые зоны  — это предполагаемая прибыль, относительно текущей позиции, в случае если позиция будет закрыта  на уровне реверса.
Про ошибку… На графике ниже по Eu-12.20 видна ошибка, возникшая в рекомендации:
Визуализация рекомендаций Романа Андреева на Python. Часть 2. Компьютерное зрение.
Очевидно, что Роман имел ввиду что позиция по 92350 от 6 ноября должна была иметь направление «шорт», т.к. предыдущая позиция была «лонгом».
А что подумали вы?

Подведем итог и повторим алгоритм действий:
1. В Смартлабе заходите в блог Романа Андреева, копируете табличку с рекомендациями и сохраняете изображение в папке RomanAndreev\Data\ под именем даты рекомендации, например 2020-12-07.png
2. Запускаете распознавание StartRunning('2020-12-07') в первой ячейке, которая сохранит рекомендацию в формате csv.
3. Проверяете экспорт с Финама — запуском второй ячейки, если все ок, то
4. Визуализация — запуск третьей ячейки.
В принципе весь код можно объединить.  Не забудьте про исходные данные RomanAndreev

Буду рад, если эта статья поможет вам в заработке. Лично я, пока потерял во времени: 4 часа — написание кода, 1 час — подготовка шаблонов для распознавания, 2 часа на эту статью. Умножаем на 1700 руб/час = порядка 12тр. Понятно, что время бесценно. Но если кому-то придет в голову отблагодарить автора фиатом, я не буду против донатов. Мошна намбэр 410012324117195 на Яндекс Юмани. Собранный миллион разделим по-честному — 45% Роману, 45% -мне, 10% Остап Ибрагимовичу Тимофею. Если оставите мыло, все обновления будут у вас на мыле. Ну а так — пользуемся бесплатно и делаем деньги.

ЗЫ. Забыл самое главное — СПАСИБО РОМАНУ АНДРЕЕВУ ЗА ЕГО ТРУД!

Даже не хочу упоминать, что у меня есть канал на ютьюбе (чтобы не забили камнями)








76 Комментариев
  • Семён Семёныч
    07 декабря 2020, 23:07
    А, что делать будет распознаватель когда Роман, по ошибке, вместо шорта напишет лонг? :) ну или цифры в запарке перепутает?
      • Семён Семёныч
        08 декабря 2020, 08:01
        Евгений Шибаев, Я двумя руками за Ваши старания! ОЧень красиво получилось! Надеюсь поможет даже кому-то заработать…
  • Константин Доронин
    07 декабря 2020, 23:22
    Спасибо. С комментами — самый сок.
      • Дед Панас
        08 декабря 2020, 13:07
        Евгений Шибаев, вопрос, а где вывод? В плюс торгует или нет?
         Почему уелыкий гуру не выйдет на ЛЧИ и не докажет умение торговать?))
           Ах да был два года назад что то вроде -20% показал.
          Больше не ногой,)))
          • Дед Панас
            08 декабря 2020, 15:59
            Евгений Шибаев, бу га га
          • Дед Панас
            08 декабря 2020, 22:19
            Евгений Шибаев, если ты не знаешь результата, то загугли, что такое


                сизифов труд
              • Дед Панас
                08 декабря 2020, 22:48
                Евгений Шибаев, сизифов труд ))и не более, удачи вам и как программисту уважуха, потому что коллеги
  • Иван Файртрейдов
    07 декабря 2020, 23:23
    чтото мне кажется что логика уровней самого Романа проще чем ваша прога :) но все равно круто
  • ghjvf
    07 декабря 2020, 23:35
    Ошибки ))) да они есть и часто раньше были, но Роман молодец — он их комментировал… помню с появления его поста тысячи просмотром
    И комментов было… тут же… эээ я просто года три не заходил на рынок вообще… и тут такое беда… автору статьи БРАВО!!!
  • Врач-бондиатОр
    07 декабря 2020, 23:38
    Такое за несколько часов написать… Очень круто. Завидую Вашему таланту:)
  • GOLD
    07 декабря 2020, 23:48
    Пост наглядно показывает важность человека, способного сформулировать задачи для программиста.
  • Replikant_mih
    08 декабря 2020, 00:48

    Мощно).

    Ещё сюда парсер просится, который картинки сам парсит из всех постов.

     

    У меня б больше времени ушло, наверно).

      • Replikant_mih
        08 декабря 2020, 09:04
        Евгений Шибаев, Ну эт да, общение это дело хорошее. Правда именно туда я никогда не заходил. Вернее один раз зашел посмотреть откуда там всегда столько сообщений, увидел, что там только одних приветов дофига, закрыл).
  • Ilya
    08 декабря 2020, 02:40
    вывод-то какой? 
    • technic
      08 декабря 2020, 03:19
      Ilya, простой рома торгует в плюс
      • GrayFox
        08 декабря 2020, 03:26
        technic, есть выписки?
        • technic
          08 декабря 2020, 03:33
          GrayFox, посмотрите внимательно картинки
  • technic
    08 декабря 2020, 03:17
    Как то вы неправильно час работы считаете, нужно брать ставку сеньор девелопер из силиконовой долины в долларах конечно))
  • Kot_Begemot
    08 декабря 2020, 07:02
     

    Вот только не ясно зачем делать blur, если он заведомо ухудшает качество и почему нет ресайза. Получается шаблон мы ищем определенного размера, так?
      • Kot_Begemot
        08 декабря 2020, 11:07
        Евгений Шибаев, вы так меня спрашиваете, как буд-то я профессионально этим занимался. А я даже Питон не знаю)))

        Но особо там придумывать нечего — масштабируете спектр картинки в кратное число раз (промежуточные масштабы тоже сработают кое-как), и гуляете по своему изображению с набором спектральных шаблонов. 

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

        Там всё ой как не просто, поэтому я и спросил вас)
  • Igor_engineer
    08 декабря 2020, 09:23
    Всё это здорово, одно НО, не заработать этой табличкой, и не надо прикрываться тем, что типа надо правильно входить. 1700руб. за час работы? Похвально, — гы… автор, да вы лучше код пишите — больше заработаете, нафиг вам этот трейдинг
      • Igor_engineer
        08 декабря 2020, 14:36
        Евгений Шибаев, 
        Трейдингом сильно больше — уже почти 30 лет.
        … Да-да, ну а я в 91-ом фичу кодил на квик бейсике, которая шортила Pan American World Airways
  • Евгений Петров
    08 декабря 2020, 12:14
    Молодец! :-)
    Тоже что ли заняться Питоном, а то отстал от жизни давно…
      • Igor_engineer
        08 декабря 2020, 14:46
        Евгений Шибаев, А что перспективнее Питон или Джава? Я вот Джаву пытаюсь изучать(очень мало времени). Вот еще анlроид разработку начал — просто пытаюсь вАйти в Айти)) С андроид-разработки мне кажется проще, ибо джава там так поскольку-постольку, начинать проще с простого.

          • Igor_engineer
            08 декабря 2020, 15:52
            Евгений Шибаев, Просто пашу на работе инженеришкой за жалкие 50 тыщ, хочу вАйти в Айти)) Знаю, что у вас 50 тыщ — это удел новичка или чела как на стройке разнорабочий. Вот и стал изучать джаву на джавараш, но к сожалению мало времени, и решил освоить андроид-разработку, так как это джава лайт по-сути. Раньше делал сайты на пыхе, но очень пожалел, что пошел инженером после универа((
              • Врач-бондиатОр
                08 декабря 2020, 18:34
                Евгений Шибаев, чертежик кинете метательного ножа? Давно хочу такой выпилить :)
                  • Врач-бондиатОр
                    08 декабря 2020, 20:02
                    Евгений Шибаев, а разве лезвие не должно быть тяжелее рукоятки для метания?
        • ANTI_Finsov
          18 декабря 2020, 14:40
          Igor_engineer, андроид не так прост. Нет единного станрдарта. Под каждый гаджет пили свой код. Да и на kotlin сейчас многие посматривают в плане мобильной разработки. Учи лучше джаву.
  • Igor_engineer
    08 декабря 2020, 14:38
    Андрей Андреичъ, Абсолютно верно. Проверял его «систему» — сливает, увы… Скажу больше- лет 5 назад в его группе было намноооого больше народу. А сейчас с гулькин нос — толи денег у людей меньше стало, толи все поняли, что его система — это реклама его платных услуг.
  • pol_unlim
    08 декабря 2020, 18:38
    Красава, автоматизация — наше всё

  • svgr
    08 декабря 2020, 19:11
    Ну, так суть системы выкристаллизовалась? В виде правил.
      • svgr
        08 декабря 2020, 20:16
        Евгений Шибаев, разве это я спросил? Вы для себя то её выяснили?
  • Сергей Давыдов
    08 декабря 2020, 21:35
    Добрый день, Евгений! В прошлом посте Вы говорили, что примерно к 14 ноября закончите работы по коннекторам, которые анонсировали в середине октября. Удалось?
      • Сергей Давыдов
        08 декабря 2020, 21:47
        Евгений Шибаев, Понял. А там ещё речь шла о коннекторе к Питону (получение любых данных и индикаторов Джато в Питон и управление заявками из Питона в Джато)…
          • Сергей Давыдов
            30 декабря 2020, 14:34
            Евгений Шибаев, Евгений, добрый день! Удаётся в этом году?
              • Сергей Давыдов
                10 января 2021, 19:25
                Евгений Шибаев, Евгений, поздравляю с наступившим НГ! Желаю эффективности во всех начинаниях! И поставлена ли «точка» в связи окончанием каникул?
  • Сергей Брониславович
    11 декабря 2020, 14:42
    А у меня в третьей ячейке ошибка
    daily_df[filedate] = pd.read_csv(f'RomanAndreev/Data/{file}', index_col = 'Tiker')
    NameError: name 'pd' is not defined

    Кстати в списке инструментов tickers = {}  после 'LKOH': 'Лукойл'   нет запятой…
      • Сергей Брониславович
        11 декабря 2020, 15:21
        Евгений Шибаев, Запустил. Так и делал — по очереди 1,2 и 3 модули но 3 модуль не видел pd хотя я видел его во втором блоке и только после отдельного определения в pd 3 модуле он запустился Почему не знаю. А вот вместе в одном файле все три модуля без проблем. Правда пришлось добавить plt.show()   в конце  start_function() Без этого графики не выводились
        Мне понравилось, на Python никогда не пытался ничего делать Здорово.
        Может попробую :)
        С точки зрения практического использования то конечно реверсивные системы спорное дело...  А вот уровни по второй картинке и таблицы в комментариях от Андреева, да еще с выводом на графики Quik посредством обработки подготовленных на Python файлов индикатором Lua было бы наглядно.  Я Квик поставил только по просьбе сына присмотреть за его сделками. Последний раз видел это творение лет «дцать» назад — ну просто слов нет что за архаизм! И где наши самые лучшие в мире программисты?!
        Удачи, спасибо за коды.
      • Сергей Брониславович
        11 декабря 2020, 15:35
        Евгений Шибаев, я их запускал в IDLE Python в трех окнах по очереди.
  • Сергей Брониславович
    11 декабря 2020, 23:13
    Евгений, поясните пожалуйста выражение  w, h = template.shape[::-1]  или ссылку Особенно по [::-1] 
      • Сергей Брониславович
        15 декабря 2020, 14:27
        Евгений Шибаев, Спасибо, разобрался Чтобы понять пригодно ли это для меня в практическом смысле попробовал другие варианты распознавания таблиц Качество слабовато. Попробовал метод cv2.findContours() с выделением ячеек таблиц с последующей обработкой pytesseract.image_to_string() но вот этот фрагмент кода выдает ошибку
        coordinates.sort() #sort by x
        coordinates.sort(key = sort2) # sort by y
        for coord in coordinates:
            x,y,w,h = coord
            print(x,y,w,h)
            if y>prev_y+5: #new row if y is changed
                recognized_table.append(row)
                row = []
            crop_img = image[y:y+h, x:x+w]
        в последней строке не принимает координаты «string indices must be integers»
        Не могу понять в чем дело

           
  • Товарищ Айван
    12 декабря 2020, 15:31

    если б к этим людям подключить динамо машину… )))

  • Александр Элс
    18 декабря 2020, 13:53
    Ого, быстро пишешь! Я недавно начал питон изучать. Чтобы небольшие перестановки и вывод таблицы на пандас сделать столько же врени трачу. На такой код еще годик ущел бы ))
  • Ronin From Moscow
    19 декабря 2020, 19:30

    Приветствую, Евгений.
    В словаре tickers пропущена запятая в строке с ключом LKOH.

    … а еще много странностей, функции возвращают данные, но при вызове эти данные не принимаются, например, PlotDatesX.

    … или вот не пойму в юпитере общая область видимости? в draw_candles вызывается PlotDatesX, где с переданной fig производятся манипуляции, но изменения не возвращаются функцией!

    В питоне вроде бы должно быть так:

    def foo(a):
        a = 3  # локальное изменение

    if __name__ == '__main__':
        a = 1
        foo(a)
        print(a) # в результате все равно 1. манипуляции в foo не оказывают никакого влияния, ведь изменения не были возвращены и приняты.

    В личку написать карма не позволяет, поэтому сюда.

      • Паша
        02 января 2021, 23:30
        Евгений Шибаев, так вот откуда у 1С эти принципы…
      • Ronin From Moscow
        08 января 2021, 11:34
        >> В словаре tickers пропущена запятая в строке с ключом LKOH.
        Евгений Шибаев, хороший способ отловить ошибки — собрать проект из кода в статье.

        tickers = {'RI-' : 'RI-12.20',
                   ...
                   'LKOH' : 'Лукойл'
                   'MTSS' : 'MTC',
                   ...
                  }

Активные форумы
Что сейчас обсуждают

Старый дизайн
Старый
дизайн