├── requirements.txt ├── .gitignore ├── moex-bonds.png ├── __main__.py ├── inc ├── __init__.py ├── Db.py ├── Analytics.py ├── Models.py └── Moex.py ├── readme.md └── routes.py /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | requests 3 | sqlalchemy 4 | pandas -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /.idea/ 3 | /_creds/ 4 | /_db/ 5 | __pycache__ -------------------------------------------------------------------------------- /moex-bonds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AzzraelCode/moex-bonds/HEAD/moex-bonds.png -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | from routes import cli_group 2 | 3 | if __name__ == '__main__': 4 | print("Hola, Azzrael Code YouTube subs!") 5 | cli_group() -------------------------------------------------------------------------------- /inc/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['moex', 'db', 'an'] 2 | 3 | from inc.Analytics import Analytics 4 | from inc.Db import Db 5 | from inc.Moex import Moex 6 | 7 | moex = Moex() 8 | db = Db() 9 | an = Analytics(db) -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Скринер Облигаций на базе ISS MOEX 2 | 3 | Через запросы к бесплатному API Мосбиржи (ISS MOEX) собираю все облигации на бирже и некоторые их метрики: 4 | - дата начала торгов 5 | - дата погашения 6 | - размеры купонов 7 | - расчитанная мосбиржей доходность 8 | - объемы торгов 9 | .. и ряд других 10 | 11 | Метрики сохраняю в SQLLite базу (простая, файловая, локальная). Для дальнейшего анализа. В коде есть несколько примеров 12 | анализа и построений отчетов с использованием Pandas. Однако можно использовать любые инструменты для работы с SQL базами. 13 | 14 | ### Это суперальфапревью версия ;) 15 | 16 | Что-то или всё может не работать. 17 | 18 | По большому счету это код для личного использования, экспериментов и для съемки видео на канале https://www.youtube.com/c/AzzraelCode 19 | Рекомендую посмотреть остальные мои видео в плейлисте ISS MOEX https://www.youtube.com/watch?v=lkrwSLpeN1I&list=PLWVnIRD69wY62qRnOw8EjaKyC8buYe1GH 20 | 21 | 22 | ## Как использовать 23 | 24 | 1. Создать SQLLite базу (файл) можно с использованием SQLLite Studio (или любой другой программы) с полями описанными в модели 25 | ( или используй SQLAlchemy для создания таблицы из модели ;) ). И положить её в папку _db проекта. 26 | 27 | 2. Установить питон и зависимости 28 | python -m pip install -r requirements.txt 29 | 30 | 3. В консоли (cmd) перейти в директорию установки и 31 | `python __main__.py` 32 | для получения списка доступных комманд 33 | -------------------------------------------------------------------------------- /inc/Db.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from importlib import resources 3 | 4 | from sqlalchemy import create_engine, func, desc, and_, or_ 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | from inc.Models import Bond 8 | import pandas as pd 9 | 10 | class Db: 11 | def __init__(self): 12 | with resources.path("_db", "db.db") as path: 13 | engine = create_engine(f"sqlite:///{path}") 14 | _session = sessionmaker() 15 | _session.configure(bind=engine) 16 | self.session = _session() 17 | 18 | def get_df(self): 19 | return pd.read_sql(self.session.query(Bond).statement, self.session.bind) 20 | 21 | 22 | def add_bond(self, j): 23 | """ 24 | Добавляю новую облигу 25 | или обновляю ту что уже в базе 26 | :param j: 27 | :return: 28 | """ 29 | o = self.session.query(Bond).filter_by(secid=j['secid']).first() 30 | if not o: o = Bond() 31 | 32 | o.from_json(j) 33 | self.session.add(o) 34 | 35 | def update_bond_from_json(self, bond : Bond, j:dict): 36 | """ 37 | Обновление облиги 38 | запись спеков и доходностей 39 | :param bond: 40 | :param j: 41 | :return: 42 | """ 43 | bond.from_json(j) 44 | bond.updated = datetime.now() 45 | self.session.add(bond) 46 | 47 | def get_random_bond(self) -> Bond: 48 | return self.session.query(Bond).filter_by(is_traded=True).order_by(func.random()).first() 49 | 50 | def get_next_bond(self, seconds = 18000 ) -> Bond: 51 | before = (datetime.now() - timedelta(seconds=seconds)) 52 | return self.session.query(Bond).filter(and_( or_(Bond.updated == None, Bond.updated < before), Bond.is_traded == True)).order_by(desc(Bond.updated)).first() -------------------------------------------------------------------------------- /routes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import click 3 | from inc import moex, db, an 4 | 5 | def timediff(start : datetime): 6 | d = datetime.datetime.now() - start 7 | return datetime.datetime.fromtimestamp(d.total_seconds()).strftime("%M:%S") 8 | 9 | @click.command() 10 | def get_bonds(): 11 | """ 12 | Парсинг всех (вкл не торгуемые) облигаций, кот отдает ISS MOEX 13 | и добавление в базу или обновление в базе 14 | минимальное колво инфы по каждой их облиг 15 | :return: 16 | """ 17 | start_time = datetime.datetime.now() 18 | 19 | # обновление списка облиг 20 | # добавление новых, смена статуса и т.д. 21 | # без спеков и доходностей - только secid, isin, boiard id n etc. 22 | for p in range(1, 1000): 23 | bonds = moex.get_bonds(p, 100) 24 | 25 | if len(bonds) < 1: 26 | click.secho(f"Закончила обновлять список облигаций на стр. № {p}", fg='green') 27 | break 28 | 29 | [db.add_bond(j) for j in bonds] 30 | db.session.commit() 31 | click.echo( click.style(timediff(start_time), fg='yellow') + f" / page {p}") 32 | 33 | # добаляю спеки облиги (их тоже нужно обновлять, напр за дату след купона) 34 | # добалвю расчет доходностей yields (кот мосбиржа считает раз в сутки по пред дню) 35 | # считаю только те что is_traded = True, это ~2700 из 8000 облиг 36 | while True: 37 | bond = db.get_next_bond(60*60*24) 38 | if not bond: 39 | click.secho(f"Закончила обновлять", fg='green') 40 | break 41 | 42 | db.update_bond_from_json(bond, moex.get_specs(bond.secid)) 43 | db.update_bond_from_json(bond, moex.get_yield(bond.secid)) 44 | db.session.commit() 45 | 46 | click.echo( click.style(timediff(start_time), fg='yellow') + " / " + str(bond)) 47 | 48 | @click.command() 49 | def stats(): 50 | for k, v in an.get_main_stats().items(): 51 | click.echo(click.style(k, fg='bright_white') + " .. " + click.style(v, fg='green')) 52 | 53 | @click.command() 54 | @click.option('--rep', '-r', default='lowest_price', show_default=True, required=False) 55 | def report(rep="lowest_price"): 56 | method = getattr(an, f"report_{rep}") 57 | df = method() 58 | 59 | # df = an.report_lowest_price() 60 | # df = an.report_365_yieldest() 61 | # df = an.report_365_cheap_ll21() 62 | 63 | for i, r in df.iterrows(): 64 | print(f"{r['shortname']}, {r['matdays'].days} : {r['price']}, {r['effectiveyield']} / https://www.moex.com/ru/issue.aspx?code={r['secid']}") 65 | 66 | click.echo("report %s, нашла %s облиг" % ( 67 | click.style(f"{rep}", fg='green'), 68 | click.style(f"{len(df)}", fg='green') 69 | )) 70 | 71 | @click.command() 72 | def test(): 73 | b = db.get_random_bond() 74 | j = moex.get_yield(b.secid) 75 | 76 | db.update_bond_from_json(b, j) 77 | db.session.commit() 78 | click.echo([j, b.primary_boardid]) 79 | 80 | 81 | @click.group() 82 | def cli_group(): 83 | pass 84 | 85 | cli_group.add_command(report) 86 | cli_group.add_command(stats) 87 | cli_group.add_command(get_bonds) 88 | cli_group.add_command(test) -------------------------------------------------------------------------------- /inc/Analytics.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pandas as pd 4 | 5 | from inc.Db import Db 6 | 7 | class Analytics: 8 | def __init__(self, db : Db): 9 | self.df = db.get_df() 10 | self.df['matdays'] = self.df['matdate'] - datetime.now() 11 | 12 | def get_main_stats(self): 13 | date_2022 = datetime.strptime("2022-01-01", "%Y-%m-%d") 14 | date_2021 = datetime.strptime("2021-01-01", "%Y-%m-%d") 15 | date_2020 = datetime.strptime("2020-01-01", "%Y-%m-%d") 16 | date_2019 = datetime.strptime("2019-01-01", "%Y-%m-%d") 17 | delta365 = timedelta(days=365) 18 | df = self.df 19 | 20 | stats = { 21 | 'всего облиг' : len(df.index), 22 | 'торгуемых' : len(df[ df['is_traded'] == 1].index), 23 | 'для квалов' : len(df[ df['isqualifiedinvestors'] == True].index), 24 | 25 | 'выпущенных в 2021' : len(df[ (df['issuedate'] >= date_2021) & (df['issuedate'] <= date_2022)].index), 26 | 'выпущенных в 2020' : len(df[ (df['issuedate'] >= date_2020) & (df['issuedate'] <= date_2021)].index), 27 | 'выпущенных в 2019' : len(df[ (df['issuedate'] >= date_2019) & (df['issuedate'] <= date_2020)].index), 28 | 29 | 'с доходностью > 1%' : len(df[ df['effectiveyield'] >= 1].index), 30 | 'с доходностью > 8%' : len(df[ df['effectiveyield'] >= 8].index), 31 | 'с доходностью > 11%' : len(df[ df['effectiveyield'] >= 11].index), 32 | 33 | 'листинг 1' : len(df[ (df['is_traded'] == 1) & (df['listlevel'] == 1)].index), 34 | 'медианная доходность, листинг 1, %' : round(df[ (df['is_traded'] == 1) & (df['listlevel'] == 1)]['effectiveyield'].median(),2), 35 | 'листинг 2' : len(df[ (df['is_traded'] == 1) & (df['listlevel'] == 2)].index), 36 | 'медианная доходность, листинг 2, %' : round(df[ (df['is_traded'] == 1) & (df['listlevel'] == 2)]['effectiveyield'].median(),2), 37 | 'листинг 3' : len(df[ (df['is_traded'] == 1) & (df['listlevel'] == 3)].index), 38 | 'медианная доходность, листинг 3, %' : round(df[ (df['is_traded'] == 1) & (df['listlevel'] == 3)]['effectiveyield'].median(),2), 39 | 40 | 'медианная цена, %' : round(df['price'].mean(),2), 41 | 42 | 'медианная цена, с дох >= 11, %' : round(df[ df['effectiveyield'] >= 11]['price'].median(),2), 43 | 'медианная цена, с дох >= 8 & < 11, %' : round(df[ (df['effectiveyield'] >= 8) & (df['effectiveyield'] < 11)]['price'].median(),2), 44 | 'медианная цена, с дох >= 1 & < 8, %' : round(df[ (df['effectiveyield'] >= 1) & (df['effectiveyield'] < 8)]['price'].median(),2), 45 | 46 | 'медианная цена, листинг 1, %' : round(df[ (df['is_traded'] == 1) & (df['listlevel'] == 1)]['price'].median(),2), 47 | 'медианная цена, листинг 2, %' : round(df[ (df['is_traded'] == 1) & (df['listlevel'] == 2)]['price'].median(),2), 48 | 'медианная цена, листинг 3, %' : round(df[ (df['is_traded'] == 1) & (df['listlevel'] == 3)]['price'].median(),2), 49 | 50 | 'медианная цена, matday < 365, %' : round(df[df['matdays'] < delta365]['price'].median(),2), 51 | 'медианная доходность, matday < 365, %' : round(df[df['matdays'] < delta365]['effectiveyield'].median(),2), 52 | 53 | } 54 | 55 | return stats 56 | 57 | def report_lowest_price(self, min_normal=90): 58 | return self.df[self.df['price'] < min_normal].sort_values(by=['effectiveyield'], ascending=False) 59 | 60 | def report_365_cheap_ll21(self): 61 | """ 62 | Облиги с ценой ниже медианы, в лл 1-2 и с погашением в сл 365 дней 63 | :return: 64 | """ 65 | delta365 = timedelta(days=365) 66 | df = self.df[ (self.df['is_traded'] == 1) & (self.df['listlevel'] == 2) & (self.df['matdays'] < delta365)] 67 | med = df['price'].median() 68 | return df[df['price'] < med].sort_values(by=['price'], ascending=True) 69 | 70 | def report_365_yieldest(self): 71 | """ 72 | Облиги погашаемые в след 365 дней и в листингах 1 и 2 73 | доходностью выше медианной в этой группе 74 | :return: 75 | """ 76 | delta365 = timedelta(days=365) 77 | df = self.df[(self.df['matdays'] < delta365) & (self.df['listlevel'] <= 2)] 78 | med = df['effectiveyield'].median() 79 | return df[df['effectiveyield'] > med].sort_values(by=['effectiveyield'], ascending=False) 80 | 81 | -------------------------------------------------------------------------------- /inc/Models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime 4 | from sqlalchemy.orm import declarative_base 5 | 6 | Base = declarative_base() 7 | 8 | class Bond(Base): 9 | """ 10 | https://www.moex.com/ru/listing/securities.aspx 11 | https://www.moex.com/ru/issue.aspx?code=secid 12 | https://www.moex.com/ru/issue.aspx?code=RU000A1047S3 13 | 14 | К сож. в беспл ISS MOEX не доступны orderbook ни в каком видео 15 | """ 16 | __tablename__ = "bonds" 17 | id = Column(Integer, primary_key=True) 18 | secid = Column(String) 19 | shortname = Column(String) 20 | 21 | price = Column(Float) # цена в проц от номинала 22 | tradedate = Column(DateTime) # дата посл торгов, если при последней проверке торгов не было - заношу дату проверки 23 | yieldsec = Column(Float) # расчитанная мосбиржей доходность в соотв. запросе (может отличаться нюансами) 24 | volume = Column(Integer) # объем торгов на посл сессиий в выбраном режиме торгов (board) 25 | matdate = Column(DateTime) # Дата погашения 26 | couponfrequency = Column(Integer) # частота выплаты купона 27 | couponpercent = Column(Float) # купон % 28 | listlevel = Column(Integer) # Уровень листинга - 1 круто, 3 - нет 29 | 30 | updated = Column(DateTime) 31 | 32 | is_traded = Column(Boolean) 33 | emitent_id = Column(Integer) 34 | type = Column(String) # тип облиги (корп, офз, муниц) 35 | primary_boardid = Column(String) # осн. режим торгов (board) 36 | 37 | issuedate = Column(DateTime) # Дата начала торгов 38 | initialfacevalue = Column(Integer) # Первоначальная номинальная стоимость 39 | faceunit = Column(String) # валюта 40 | issuesize = Column(Integer) # объем выпуска 41 | facevalue = Column(Float) # Номинальная стоимость 42 | coupondate = Column(DateTime) # дата след купона (при условии обновленной базы) 43 | couponvalue = Column(Float) # купон нв деньгах 44 | isqualifiedinvestors = Column(Boolean) # только для квалов 45 | earlyrepayment = Column(Boolean) # Возможен досрочный выкуп 46 | 47 | 48 | 49 | def cast(self, val, _type, _key): 50 | """ 51 | Приведение типов 52 | ISS не всегда отдает number в нужном видео, иногда number в ответе - это str, соотв нужно привести 53 | Даты тоже нужно привести к datetime 54 | :param val: 55 | :param _type: 56 | :param _key: 57 | :return: 58 | """ 59 | try: 60 | if not val: return val 61 | 62 | if isinstance(_type, Integer) : val = int(val) 63 | elif isinstance(_type, String) : val = str(val) 64 | elif isinstance(_type, Float) : val = float(val) 65 | elif isinstance(_type, Boolean) : val = int(val) # для sqllite так 66 | elif isinstance(_type, DateTime) : val = datetime.strptime(val, "%Y-%m-%d") 67 | except Exception as e: 68 | # для дебага 69 | print([val, _type, _key, self.secid]) 70 | exit(0) 71 | 72 | return val 73 | 74 | def from_json(self, j): 75 | """ 76 | Данные из json формата в модель по аттрибутам 77 | минус способа - аттрибуты должно одинаково именоваться json => model => table 78 | это не всегда удобно 79 | :param j: 80 | :return: 81 | """ 82 | for col in self.__table__.columns: 83 | if col.key in j: 84 | val = self.cast(j[col.key], col.type, col.key) 85 | setattr(self, col.key, val) 86 | 87 | 88 | def get_date_str(self, field = 'issuedate', _format = '%Y-%m-%d'): 89 | """ 90 | Формат поля даты из timestamp в строку формата _format 91 | :param field: 92 | :param _format: 93 | :return: 94 | """ 95 | val = getattr(self, field) 96 | if not val : return "n/a" 97 | return val.strftime('%Y-%m-%d') 98 | 99 | def get_url(self): 100 | """ 101 | Инфа по выпуску 102 | https://www.tinkoff.ru/invest/bonds/{secid}/ 103 | 104 | :return: 105 | """ 106 | return f"https://www.moex.com/ru/issue.aspx?code={self.secid}" 107 | 108 | def __str__(self): 109 | issuedate = self.get_date_str() 110 | tradedate = self.get_date_str('tradedate') 111 | return f"{self.secid} / {self.shortname}, {issuedate} = {self.is_traded} / {tradedate} = {self.yieldsec}" 112 | -------------------------------------------------------------------------------- /inc/Moex.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | from urllib import parse 4 | import requests 5 | import pandas as pd 6 | 7 | class Moex: 8 | def query(self, method : str, **kwargs): 9 | """ 10 | Отправляю запрос к ISS MOEX 11 | :param method: 12 | :param kwargs: 13 | :return: 14 | """ 15 | try: 16 | url = "https://iss.moex.com/iss/%s.json" % method 17 | if kwargs: 18 | if '_from' in kwargs: kwargs['from'] = kwargs.pop('_from') # костыль - from нельзя указывать как аргумент фн, но в iss оно часто исп 19 | url += "?" + parse.urlencode(kwargs) 20 | 21 | # не обязательно 22 | headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36',} 23 | 24 | r = requests.get(url, headers=headers) 25 | r.encoding = 'utf-8' 26 | j = r.json() 27 | return j 28 | 29 | except Exception as e: 30 | print("query error %s" % str(e)) 31 | return None 32 | 33 | def flatten(self, j:dict, blockname:str): 34 | """ 35 | Собираю двумерный массив (словарь) 36 | :param j: 37 | :param blockname: 38 | :return: 39 | """ 40 | return [{str.lower(k) : r[i] for i, k in enumerate(j[blockname]['columns'])} for r in j[blockname]['data']] 41 | 42 | def rows_to_dict(self, j:dict, blockname:str, field_key='name', field_value='value'): 43 | """ 44 | Для преобразования запросов типа /securities/:secid.json (спецификация бумаги) 45 | в словарь значений 46 | :param j: 47 | :param blockname: 48 | :param field_key: 49 | :param field_value: 50 | :return: 51 | """ 52 | return {str.lower(r[field_key]) : r[field_value] for r in self.flatten(j, blockname)} 53 | 54 | def get_bonds(self, page=1, limit=10): 55 | """ 56 | Получаю облигации торгуемые на Мосбирже (stock_bonds) 57 | без данных по облигации, только исин, эмитент и т.п. 58 | :param page: 59 | :param limit: 60 | :return: 61 | """ 62 | j = self.query("securities", group_by="group", group_by_filter="stock_bonds", limit=limit, start=(page-1)*limit) 63 | f = self.flatten(j, 'securities') 64 | return f 65 | 66 | def get_specs(self, secid : str): 67 | return self.rows_to_dict(self.query(f"securities/{secid}"), 'description') 68 | 69 | def get_yield(self, secid: str): 70 | path = f"history/engines/stock/markets/bonds/sessions/3/securities/{secid}" 71 | _from = (datetime.datetime.now() - datetime.timedelta(days=7)).strftime("%Y-%m-%d") 72 | _r = self.flatten(self.query(path, _from=_from), 'history') 73 | 74 | # если сделок не было, то что-то нужно записать в базу чтобы не запрашивать облигу сегодня ещё 75 | # todo: проверить - гипотетически пустые ответы могут быть сбоем 76 | if len(_r) < 1: return {'price' : 0, 'yieldsec' : 0, 'tradedate' : datetime.datetime.now().strftime("%Y-%m-%d"), 'volume' : 0} 77 | 78 | return { 79 | 'price' : _r[-1]['close'], 80 | 'yieldsec' : _r[-1]['yieldclose'], 81 | 'tradedate' : _r[-1]['tradedate'], 82 | 'volume' : _r[-1]['volume'], 83 | } 84 | 85 | def get_last_yield(self, secid: str): 86 | """ 87 | !!! Сейчас не использую, вместо него см. 88 | https://iss.moex.com/iss/reference/793 89 | Очень кривой способ 90 | - расчет вчерашним днем 91 | - нет объемов (стакан платный) 92 | - не ко всем бумагам 93 | - глючит 94 | 95 | price = Column(Float) 96 | tradedate = Column(DateTime) 97 | effectiveyield = Column(Float) 98 | 99 | :param secid: 100 | :return: 101 | """ 102 | path = f"history/engines/stock/markets/bonds/yields/{secid}" 103 | _from = (datetime.datetime.now() - datetime.timedelta(days=3)).strftime("%Y-%m-%d") 104 | _r = self.flatten( self.query(path, _from=_from), 'history_yields') 105 | 106 | # не по всем облигам (особ не публичным) вообще есть такая инфа 107 | r = {} if _r is None or len(_r) < 1 else _r[-1] 108 | # не для всех облиг есть торговля, но нужно в базе как то отмечать что проверка была, поэтому костыль ниже 109 | k = 'tradedate' 110 | if k not in r or r[k] is None: r[k] = (datetime.datetime.now() - datetime.timedelta(days=1)).strftime("%Y-%m-%d") 111 | 112 | return r 113 | --------------------------------------------------------------------------------