From 00366f7f8239eeb0dd4e1af8a420f5266085e6fe Mon Sep 17 00:00:00 2001 From: andrevis Date: Wed, 2 Jul 2025 16:09:10 +0300 Subject: [PATCH 01/10] poc --- .github/workflows/ci.yml | 3 +- db/init.sh | 18 +++++-- html/index.html | 40 +++++++++----- html/script.js | 113 +++++++++++++++++++-------------------- src/bonds/getter.py | 57 ++++++++++++++++++++ src/bonds/request.py | 99 ++++++++++++++++++++++++++++++++++ src/bot.py | 55 +++++++++++++++++++ src/db/db.py | 39 ++++++++++++++ src/filters.py | 5 ++ src/handlers/start.py | 4 +- src/handlers/web_app.py | 3 +- src/http_server.py | 67 ++++++++++++++++++----- src/main.py | 37 ++++++++----- src/tables.py | 2 - src/tables.txt | 44 +++++++++++++++ 15 files changed, 480 insertions(+), 106 deletions(-) create mode 100644 src/bonds/getter.py create mode 100644 src/bonds/request.py create mode 100644 src/bot.py create mode 100644 src/db/db.py create mode 100644 src/filters.py delete mode 100644 src/tables.py create mode 100644 src/tables.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 479c957..f0bef12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,8 @@ jobs: run: | python3 -m ensurepip python3 -m venv /opt/certbot/ - pip3 install aiogram tomli certbot + python3 -m pip install --upgrade pip + pip3 install aiogram apscheduler tomli certbot py-postgresql # - name: Install some html stuff # run: | diff --git a/db/init.sh b/db/init.sh index 6f37319..7762858 100755 --- a/db/init.sh +++ b/db/init.sh @@ -4,7 +4,17 @@ db_name=$1 db_user=$2 db_pass=$3 -psql -U postgres -c "DROP DATABASE ${db_name};" -psql -U postgres -c "DROP USER ${db_user};" -psql -U postgres -c "CREATE USER ${db_user} PASSWORD '${db_pass}';" -psql -U postgres -c "CREATE DATABASE ${db_name} OWNER=${db_user};" \ No newline at end of file +# psql -U postgres -c "DROP DATABASE ${db_name};" +# psql -U postgres -c "DROP USER ${db_user};" + +psql -U postgres ${db_pass} << EOF +CREATE USER ${db_user} PASSWORD '${db_pass}'; +CREATE DATABASE ${db_name} OWNER=${db_user}; +\c ${db_name}; + + +CREATE TABLE IF NOT EXISTS options ( + id SERIAL, + name VARCHAR(100), + value text); +EOF diff --git a/html/index.html b/html/index.html index b050fd5..75a0916 100644 --- a/html/index.html +++ b/html/index.html @@ -7,7 +7,7 @@ - + @@ -30,31 +30,31 @@
- +
- +
-
- +
-
+
- +
-
- +
-
+
@@ -68,11 +68,21 @@
-
- +
+ +
+ + + +
+
+
+ +
-
-
@@ -84,10 +94,12 @@

- +
- +
+ +
diff --git a/html/script.js b/html/script.js index 0758bdc..2d0ec3d 100644 --- a/html/script.js +++ b/html/script.js @@ -1,42 +1,28 @@ - - - -// import { parse } from './module.js'; - -// var config = toml.parse("/opt/jbond/jbond.toml"); - -// $.get('/config/settings.toml', function (data) { -// var config = toml.parse(data); -// console.log(config); -// }); - - -// fs.readFile('/config/settings.toml', function (err, data) { -// var parsed = toml.parse(data); -// console.log(parsed); -// }); - - let tg = window.Telegram.WebApp; tg.expand(); // Expand the app to full screen tg.ready(); // Notify Telegram that the app is ready -function send_data() { +function send_filters() { + let filters = { is_qual: document.getElementById('is_qual').checked, is_amort: document.getElementById('is_amort').checked, duration: { - // from: parseInt(document.querySelector(".duration .input .field .from").value), - // to: parseInt(document.querySelector(".duration .input .field .to").value), - - // from: parseInt(document.getElementById('duration.from').value), - // to: parseInt(document.getElementById('duration.to').value), + fr: parseInt(document.getElementById('duration-from').value), + to: parseInt(document.getElementById('duration-to').value), }, - - // toString() { - // return `{"is_qual": ${this.is_qual}, "is_amort": ${this.is_amort}}`; - // } + profit: { + fr: parseInt(document.getElementById('profit-from').value), + to: parseInt(document.getElementById('profit-to').value), + }, + coupons: { + fr: parseInt(document.getElementById('coupons-from').value), + to: parseInt(document.getElementById('coupons-to').value), + }, + rating: document.getElementById('rating-from').value, + period: Array.from(document.getElementsByClassName("coupon-period-ckeckbox")).map((elem) => parseInt(elem.value)), + chat_id: tg.initDataUnsafe.user.id }; document.getElementById('filters').value = JSON.stringify(filters); @@ -45,17 +31,28 @@ function send_data() { method: "POST", body: JSON.stringify(filters), headers: { - "Content-type": "application/json; charset=UTF-8" + "Content-type": "application/json" } + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + document.getElementById('data').value = JSON.stringify(data); + + }) + .catch(error => { + console.error('Error:', error); }); - //.then((response) => response.json()); - //.then((json) => console.log(json)); } // tg.MainButton.setText('Готово'); // tg.MainButton.show(); // tg.MainButton.onClick(() => { -// send_data(); +// send_filters(); // // tg.sendData(json); // tg.close(); // }); @@ -63,7 +60,9 @@ function send_data() { let btn_ok = document.getElementById("btn_ok"); btn_ok.addEventListener('click', () => { - send_data(); + send_filters(); + // tg.sendData(tg.initDataUnsafe.user.id.toString()); + //tg.close(); }); @@ -97,9 +96,9 @@ durationTo.addEventListener('change', function () { -// Слайдер полной доходности -var fullProfitSlider = document.getElementById('full-profit-slider'); -noUiSlider.create(fullProfitSlider, { +// Слайдер доходности +var profitSlider = document.getElementById('profit-slider'); +noUiSlider.create(profitSlider, { start: [20.0, 50.0], connect: true, range: { @@ -108,26 +107,26 @@ noUiSlider.create(fullProfitSlider, { } }); -var duratfullProfitFrom = document.getElementById('full-profit-from'); -var duratfullProfitTo = document.getElementById('full-profit-to'); -var fullProfits = [duratfullProfitFrom, duratfullProfitTo]; +var profitFrom = document.getElementById('profit-from'); +var profitTo = document.getElementById('profit-to'); +var profits = [profitFrom, profitTo]; -fullProfitSlider.noUiSlider.on('update', function (values, handle) { - fullProfits[handle].value = Math.round(values[handle]*2)/2; +profitSlider.noUiSlider.on('update', function (values, handle) { + profits[handle].value = Math.round(values[handle]*2)/2; }); -duratfullProfitFrom.addEventListener('change', function () { - fullProfitSlider.noUiSlider.set([null, this.value]); +profitFrom.addEventListener('change', function () { + profitSlider.noUiSlider.set([null, this.value]); }); -duratfullProfitTo.addEventListener('change', function () { - fullProfitSlider.noUiSlider.set([null, this.value]); +profitTo.addEventListener('change', function () { + profitSlider.noUiSlider.set([null, this.value]); }); //Слайдер див доходности -var divProfitSlider = document.getElementById('div-profit-slider'); -noUiSlider.create(divProfitSlider, { +var couponsSlider = document.getElementById('coupons-slider'); +noUiSlider.create(couponsSlider, { start: [20.0, 50.0], connect: true, range: { @@ -136,20 +135,20 @@ noUiSlider.create(divProfitSlider, { } }); -var divProfitFrom = document.getElementById('div-profit-from'); -var divProfitTo = document.getElementById('div-profit-to'); -var divProfits = [divProfitFrom, divProfitTo]; +var couponsFrom = document.getElementById('coupons-from'); +var couponsTo = document.getElementById('coupons-to'); +var coupons = [couponsFrom, couponsTo]; -divProfitSlider.noUiSlider.on('update', function (values, handle) { - divProfits[handle].value = Math.round(values[handle]*2)/2; +couponsSlider.noUiSlider.on('update', function (values, handle) { + coupons[handle].value = Math.round(values[handle]*2)/2; }); -divProfitFrom.addEventListener('change', function () { - divProfitSlider.noUiSlider.set([null, this.value]); +couponsFrom.addEventListener('change', function () { + couponsSlider.noUiSlider.set([null, this.value]); }); -divProfitTo.addEventListener('change', function () { - divProfitSlider.noUiSlider.set([null, this.value]); +couponsTo.addEventListener('change', function () { + couponsSlider.noUiSlider.set([null, this.value]); }); diff --git a/src/bonds/getter.py b/src/bonds/getter.py new file mode 100644 index 0000000..5a806bb --- /dev/null +++ b/src/bonds/getter.py @@ -0,0 +1,57 @@ +import requests +from logger import * +from bonds.request import BondsRequest +import json + +logger = logging.getLogger("Bond") + +class BondsGetter: + __columns__ = [] + + @property + def columns(self): + return self.__columns__ + + def __needed__(self, filters, paper): + for i, column in enumerate(self.__columns__): + if column == 'IS_QUALIFIED_INVESTORS': + value = int(paper[i]) + if value == 1 and not filters.is_qual: + return False + elif column == 'HIGH_RISK': + value = int(paper[i]) + if value == 1: + return False + + return True + + + def __filter_paper__(self, paper): + filtered_paper = {} + for i, column in enumerate(self.__columns__): + filtered_paper[column] = paper[i] + return filtered_paper + + def get(self, filters): + logger.info(f'BondsGetter:get {filters}') + + request = BondsRequest() + # https://iss.moex.com/iss/apps/infogrid/emission/rates.json?lang=ru&iss.meta=on&sort_order=dsc&sort_column=YIELDATWAP&start=0&limit=100&coupon_frequency=4,6,12&columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS,INN&coupon_percent=20,50&duration=90,1080&sec_type=stock_corporate_bond¤cyid=rub + url = str(request.lang().meta(False).sort().scroll().period(filters.period).columns().coupons(filters.coupons.fr, filters.coupons.to).duration(filters.duration.fr, filters.duration.to).sec_type().currencyid()) + logger.info(f'Request: {url}') + + response = requests.get(url, timeout=5) + + if response.status_code != 200: + logger.error(f'Cannot get MOEX request (code={response.status_code}): {response.reason}') + return None + + json = response.json() + # metadata = json["rates"]["metadata"] + self.__columns__ = json["rates"]["columns"] + + filtered_papers = [] + for paper in json["rates"]["data"]: + if self.__needed__(filters, paper): + filtered_papers.append(self.__filter_paper__(paper)) + return filtered_papers diff --git a/src/bonds/request.py b/src/bonds/request.py new file mode 100644 index 0000000..1c6b2a1 --- /dev/null +++ b/src/bonds/request.py @@ -0,0 +1,99 @@ +# https://iss.moex.com/iss/apps/infogrid/emission/rates.json? +# lang=ru& +# iss.meta=on& +# sort_order=asc& +# sort_column=SECID& +# start=0& +# limit=100& +# coupon_frequency=12,4 +# columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS,INN& +# coupon_percent=19.96,35& +# duration=1,1497& +# sec_type=stock_corporate_bond& +# currencyid=rub + +# колонки +# https://iss.moex.com/iss/apps/infogrid/emission/columns.json?lang=ru&iss.meta=off + + +class BondsRequest: + __req__ = None + __columns__ = [ + 'SECID', + 'SHORTNAME', + 'ISIN', + 'FACEVALUE', + 'FACEUNIT', + 'MATDATE', + 'COUPONFREQUENCY', + 'COUPONPERCENT', + 'OFFERDATE', + 'DAYSTOREDEMPTION', + 'SECSUBTYPE', + 'YIELDATWAP', + 'COUPONDATE', + 'PRICE_RUB', + 'PRICE', + 'REPLBOND', + 'ISSUEDATE', + 'COUPONLENGTH', + 'TYPENAME', + 'DURATION', + 'IS_QUALIFIED_INVESTORS', + 'INN' + ] + + @property + def get_columns(self): + return self.__columns__ + + def __init__(self): + self.__req__ = 'https://iss.moex.com/iss/apps/infogrid/emission/rates.json?' + + def lang(self): + self.__req__ += 'lang=ru&' + return self + + def meta(self, meta = False): + val = 'on' if meta else 'off' + self.__req__ += f'iss.meta={val}&' + return self + + def sort(self, odred = 'dsc', col = 'YIELDATWAP'): + self.__req__ += f'sort_order={odred}&sort_column={col}&' + return self + + def scroll(self, start = 0, limit = 100): + self.__req__ += f'start={start}&limit={limit}&' + return self + + def period(self, values=[]): + val = ','.join(map(str, values)) + self.__req__ += f'coupon_frequency={val}&' + return self + + def columns(self, optional = None): + columns = ','.join(self.__columns__) + self.__req__ += f'columns={columns}&' + if optional: + self.__req__ += f',{optional}' + return self + + def coupons(self, fr, to): + self.__req__ += f'coupon_percent={fr},{to}&' + return self + + def duration(self, fr, to): + self.__req__ += f'duration={fr*30},{to*30}&' + return self + + def sec_type(self): + self.__req__ += f'sec_type=stock_corporate_bond&' + return self + + def currencyid(self, value='rub'): + self.__req__ += f'currencyid={value}&' + return self + + def __str__(self): + return self.__req__[:-1] diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..52e7b3c --- /dev/null +++ b/src/bot.py @@ -0,0 +1,55 @@ + +from config import config +from aiogram import Bot +from aiogram.enums import ParseMode +from aiogram.types import LinkPreviewOptions +from logger import * +import queue + + +logger = logging.getLogger("Jbond") + +bot = Bot(token = config['bot']['token']) + +messages_queue = queue.Queue() + +class SendMessageTask(object): + def __init__(self, chat_id, paper): + self.chat_id = chat_id + self.paper = paper + + async def __call__(self): + logger.info(f'Send message to {self.chat_id}') + try: + + isin = self.paper["ISIN"] + name = self.paper["NAME"] + duration = self.paper["DURATION"] + coupon = self.paper["COUPONPERCENT"] + coupon_date = self.paper["COUPONDATE"] + yieldatwap = self.paper["YIELDATWAP"] + couponlength = self.paper["COUPONLENGTH"] + price_percent = self.paper["PRICE"] + price_rub = self.paper["PRICE_RUB"] + qual = "да" if (int(self.paper["IS_QUALIFIED_INVESTORS"]) == 0) else "нет" + + + text = f'''📌 Имя:\t{name} +🔎 ISIN:\t{isin} +💲 Цена:\t{price_percent}% ({price_rub}₽ ) +📈 Доходность:\t{yieldatwap}% +📆 Купон:\t{coupon}% (раз в {round(couponlength/30)} мес.), ближайший {coupon_date} +⏳ Дюрация:\t{duration} дней ({round(duration/30, 1)} мес) +🐹 Доступно для неквалов:\t{qual} +📞 Оферта:\t...''' + + await bot.send_message(chat_id=self.chat_id, text=text, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True)) + except Exception as e: + logger.error(f'Cannot send bot message len={len(text)}: {e}\n{text}') + + + +class MessagePack(list): + async def __call__(self): + for task in self: + await task() \ No newline at end of file diff --git a/src/db/db.py b/src/db/db.py new file mode 100644 index 0000000..d208450 --- /dev/null +++ b/src/db/db.py @@ -0,0 +1,39 @@ +import postgresql +from logger import * + +logger = logging.getLogger("SQL") + +# from bonds_request import BondsRequest + + +class Options: + __name__ = 'options' + __db__ = None + + def __init__(self, db): + self.__db__ = db + + def insert(self, name, value): + res = self.__db__.prepare(f'INSERT INTO {self.__name__} (name, value) SET (\"{name}\", \"{value}\") ON CONFLICT DO UPDATE SET value=\"{value}\";')() + logger.info(f'Options.insert: {res}') + + def get(self, name): + res = self.__db__.prepare(f'INSERT INTO {self.__name__} (name, value) SET (\"{name}\", \"{value}\") ON CONFLICT DO UPDATE SET value=\"{value}\";')() + + logger.info(f'Options.get: {res}') + if len(res) > 0: + return res[0][1] + else: + return None + + +class Psql: + __db__ = None + __options__ = None + + def __init__(self, db_name, user, password, ip='127.0.0.1', port=5432): + self.__db__ = postgresql.open(f'pq://{user}:{password}@{ip}:{port}/{db_name}') + self.__options__(self.__db__) + + def save_filters(self, filters: json): + self.__options__.insert('filters', filters) diff --git a/src/filters.py b/src/filters.py new file mode 100644 index 0000000..61abd4c --- /dev/null +++ b/src/filters.py @@ -0,0 +1,5 @@ +import json +from types import SimpleNamespace + +def parse_filters(data: json): + return json.loads(data, object_hook=lambda d: SimpleNamespace(**d)) diff --git a/src/handlers/start.py b/src/handlers/start.py index 08fbf68..ffecaae 100644 --- a/src/handlers/start.py +++ b/src/handlers/start.py @@ -17,7 +17,7 @@ async def handle_start(message: Message): webAppInfo = WebAppInfo(url = f"https://{address}:{port}") builder = ReplyKeyboardBuilder() builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) - await message.answer(text = 'Поехали', reply_markup = builder.as_markup()) + await message.answer(text = f'Поехали', reply_markup = builder.as_markup()) @start_router.message(Command('filters')) async def cmd_filters(message: Message): @@ -27,4 +27,4 @@ async def cmd_filters(message: Message): webAppInfo = WebAppInfo(url = f"https://{address}:{port}") builder = ReplyKeyboardBuilder() builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) - await message.answer(text = 'Хуильтры', reply_markup = builder.as_markup()) \ No newline at end of file + await message.answer(text = 'Хуильтры', reply_markup = builder.as_markup()) diff --git a/src/handlers/web_app.py b/src/handlers/web_app.py index 9170c2c..8a18dcc 100644 --- a/src/handlers/web_app.py +++ b/src/handlers/web_app.py @@ -9,5 +9,4 @@ @app_router.message(lambda message: message.web_app_data) async def handle_web_app_data(message: Message): logger.info(f'{message.web_app_data}') - - await message.answer('Получи') + await message.answer(f'{message.web_app_data}') diff --git a/src/http_server.py b/src/http_server.py index 9f7cf30..b5be6f4 100644 --- a/src/http_server.py +++ b/src/http_server.py @@ -1,27 +1,69 @@ -from http.server import HTTPServer, BaseHTTPRequestHandler, SimpleHTTPRequestHandler -from logger import * +from http.server import HTTPServer, SimpleHTTPRequestHandler from threading import Thread import ssl import mimetypes +import asyncio +import json + +from filters import parse_filters +from bonds.getter import BondsGetter +from logger import * +from bot import * + logger = logging.getLogger("Http") + class HTTPRequestHandler(SimpleHTTPRequestHandler): + __bonds_getter__ = BondsGetter() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # self.send_header('Transfer-Encoding', 'chunked') + # for chunk in response.iter_content(chunk_size=1024): + # chunk_header = f'{len(chunk):x}\r\n'.encode('utf-8') + # self.wfile.write(chunk_header) + # self.wfile.write(chunk) + # self.wfile.write(b'\r\n') + # self.wfile.write(b'0\r\n\r\n') + + def __send_response__(self, code, content_type=None, data=None): + self.send_response(code) + self.send_header('Content-Length', (len(data) if data else 0)) + self.send_header('Connection', 'close') + if content_type: + self.send_header('Content-Type', content_type) + self.end_headers() + if data: + self.wfile.write(data) + def do_POST(self): logger.info(f'Incoming POST {self.path} from {self.client_address}') - if self.path == '/filters': - file_length = int(self.headers['Content-Length']) - filters = self.rfile.read(file_length) - logger.info(f'Received filters: {filters}') + content_length = int(self.headers['Content-Length']) + + if self.path == '/filters' and content_length > 0: + filters = parse_filters(self.rfile.read(content_length)) + + json_bonds = self.__bonds_getter__.get(filters) + if not json_bonds: + self.__send_response__(500) + return + + self.__send_response__(200, 'application/json', bytes(json.dumps(json_bonds, indent=0), 'utf-8')) + + message_pack = MessagePack() + for i, paper in enumerate(json_bonds): + message_pack.append(SendMessageTask(filters.chat_id, paper)) + + if len(message_pack) > 0: + messages_queue.put(message_pack) - self.send_response(200) else: - self.send_response(400) + self.__send_response__(400) + return - self.send_header('Content-Length', 0) - self.send_header('Connection', 'close') - self.end_headers() def do_GET(self): logger.info(f'Incoming GET {self.path} from {self.client_address}') @@ -65,6 +107,7 @@ def __init__(self, port): certfile = '/etc/letsencrypt/live/jbond-app.ru/cert.pem', server_side = True) logger.info(f'Starting httpd server on port {self.__port__}...') + self.start() def run(self): try: @@ -73,4 +116,4 @@ def run(self): logger.info(f'HttpServer error: {e}') finally: logger.info(f'Stopping httpd server on port {self.__port__}...') - self.__httpd__.server_close() + self.__httpd__.server_close() \ No newline at end of file diff --git a/src/main.py b/src/main.py index 7f5c3cc..947a8c9 100644 --- a/src/main.py +++ b/src/main.py @@ -5,28 +5,41 @@ from http_server import HttpServer from handlers.start import start_router from handlers.web_app import app_router +from bot import * from aiogram import Bot, Dispatcher from aiogram.fsm.storage.memory import MemoryStorage +from apscheduler.schedulers.asyncio import AsyncIOScheduler logger = logging.getLogger("Main") +async def send_messages(): + while not messages_queue.empty(): + message_pack = messages_queue.get() + await message_pack() + + async def main(): - # scheduler.add_job(send_time_msg, 'interval', seconds=10) - # scheduler.start() + try: + httpd = HttpServer(port = config["server"]["port"]) + + scheduler = AsyncIOScheduler(timezone='Europe/Moscow') + scheduler.add_job(send_messages, 'interval', seconds=3) + scheduler.start() - bot = Bot(token = config['bot']['token']) + dispatcher = Dispatcher(storage = MemoryStorage()) + dispatcher.include_router(start_router) + dispatcher.include_router(app_router) - dispatcher = Dispatcher(storage = MemoryStorage()) - dispatcher.include_router(start_router) - dispatcher.include_router(app_router) + await bot.delete_webhook(drop_pending_updates = True, request_timeout = 10) + await dispatcher.start_polling(bot) - await bot.delete_webhook(drop_pending_updates = True, request_timeout = 10) - await dispatcher.start_polling(bot) + except Exception as e: + logging.error(e, exc_info=True) + finally: + httpd.join() + pass if __name__ == "__main__": - httpd = HttpServer(config["server"]["port"]) - httpd.start() - asyncio.run(main()) - httpd.join() \ No newline at end of file + asyncio.run(main(), debug=True) diff --git a/src/tables.py b/src/tables.py deleted file mode 100644 index cf88c2d..0000000 --- a/src/tables.py +++ /dev/null @@ -1,2 +0,0 @@ -DIVIDENTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-dividend-yields/xlsx' -DEFOLTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-defaults/xlsx' \ No newline at end of file diff --git a/src/tables.txt b/src/tables.txt new file mode 100644 index 0000000..bdcb66e --- /dev/null +++ b/src/tables.txt @@ -0,0 +1,44 @@ +# DIVIDENTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-dividend-yields/xlsx' + + +DEFOLTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-defaults/xlsx' + + +r + + +Погашение и амортизация: +https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=amortizations,amortizations.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 + +Оферты +https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=offers,offers.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 + +Купоны +https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=coupons,coupons.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 + +облигации +https://iss.moex.com/iss/apps/infogrid/stock/rates.json?_=1751043561209&lang=ru&iss.meta=off&sort_order=asc&sort_column=SECID&start=0&limit=100&columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,ISSUESIZE,MATDATE,COUPONFREQUENCY,COUPONPERCENT,PRICE,PRICE_RUB¤cyid=rub&faceunit=rub&bg=&sec_type=stock_corporate_bond + + +https://iss.moex.com/iss/engines/stock/markets/index/securities.json?iss.meta=off&iss.json=extended&iss.only=marketdata&marketdata.columns=SECID%2CCURRENTVALUE%2CLASTCHANGEPRC&credentials=credentials&securities=IMOEX%2CRGBITR%2CRUCBTRNS%2CRUMBTRNS%2CRUCBTR3YNS%2CRUCBTR5YNS + +Конкретная бумага +https://iss.moex.com/iss/securities/RU000A0JQAM6.json?iss.json=extended&iss.meta=off +https://www.moex.com/ru/issue.aspx?code=RU000A0NNE69 + +ВСе бумаги, но не вся инфа +https://iss.moex.com/iss/engines/stock/markets/shares/boardgroups/57/securities.json + + +ВСе бумаги с фильтрма +https://www.moex.com/s2644#/?currencyid%5B%5D='rub'&sec_type%5B%5D='stock_corporate_bond'&duration%5B%5D=1,1497&coupon_percent%5B%5D=19.96,35&columns%5B%5D='SECID','SHORTNAME','ISIN','FACEVALUE','FACEUNIT','ISSUESIZE','MATDATE','COUPONFREQUENCY','COUPONPERCENT','OFFERDATE','DAYSTOREDEMPTION','SECSUBTYPE','EMITENTNAME','YIELDATWAP','COUPONDATE','DISCOUNT3','DISCOUNT2','DISCOUNT1','PRICE_RUB','PRICE','REPLBOND','ISSUEDATE','COUPONLENGTH','COUPONDAYSREMAIN','COUPONDAYSPASSED','TYPENAME','DURATION','IS_QUALIFIED_INVESTORS' + + +api +https://iss.moex.com/iss/reference/ + +# https://iss.moex.com/iss/engines/currency/markets/selt/boardgroups/13/securities.json +# https://iss.moex.com/iss/engines/stock/markets/index/securities.js + + +# инфа https://iss.moex.com/iss/engines/stock/markets/bonds \ No newline at end of file From be45a3da0eae387d2c873dc504dbfcf593a4c7fd Mon Sep 17 00:00:00 2001 From: andrevis Date: Thu, 3 Jul 2025 16:37:34 +0300 Subject: [PATCH 02/10] fixup --- html/index.html | 48 ++++++++++++++------ html/script.js | 43 +++++++++--------- html/style.css | 11 ++++- src/bonds/getter.py | 92 ++++++++++++++++++++++++++------------ src/bonds/request.py | 54 +++++++++++++++------- src/bot.py | 52 +--------------------- src/handlers.py | 59 ++++++++++++++++++++++++ src/handlers/start.py | 30 ------------- src/handlers/web_app.py | 12 ----- src/http_server.py | 6 +-- src/main.py | 16 ++----- src/messages.py | 99 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 333 insertions(+), 189 deletions(-) create mode 100644 src/handlers.py delete mode 100644 src/handlers/start.py delete mode 100644 src/handlers/web_app.py create mode 100644 src/messages.py diff --git a/html/index.html b/html/index.html index 75a0916..78c3700 100644 --- a/html/index.html +++ b/html/index.html @@ -14,19 +14,18 @@ -
- +
-
- +
-
+
@@ -44,7 +43,7 @@
- +
@@ -71,21 +70,46 @@
- + + +
- - + + +
+
+ +
- - + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+

@@ -97,10 +121,6 @@
-
- -
- \ No newline at end of file diff --git a/html/script.js b/html/script.js index 2d0ec3d..8fcc42a 100644 --- a/html/script.js +++ b/html/script.js @@ -8,9 +8,10 @@ function send_filters() { let filters = { is_qual: document.getElementById('is_qual').checked, is_amort: document.getElementById('is_amort').checked, - duration: { - fr: parseInt(document.getElementById('duration-from').value), - to: parseInt(document.getElementById('duration-to').value), + is_offer: document.getElementById('is_offer').checked, + redemption: { + fr: parseInt(document.getElementById('redemption-from').value), + to: parseInt(document.getElementById('redemption-to').value), }, profit: { fr: parseInt(document.getElementById('profit-from').value), @@ -20,8 +21,12 @@ function send_filters() { fr: parseInt(document.getElementById('coupons-from').value), to: parseInt(document.getElementById('coupons-to').value), }, + sort: { + order: Array.from(document.getElementsByClassName("order")).filter((elem) => (elem.checked)).map((elem) => (elem.value)).toString(), + key: Array.from(document.getElementsByClassName("sort-by")).filter((elem) => (elem.checked)).map((elem) => (elem.value)).toString(), + }, rating: document.getElementById('rating-from').value, - period: Array.from(document.getElementsByClassName("coupon-period-ckeckbox")).map((elem) => parseInt(elem.value)), + period: Array.from(document.getElementsByClassName("coupon-period-ckeckbox")).filter((elem) => (elem.checked)).map((elem) => parseInt(elem.value)), chat_id: tg.initDataUnsafe.user.id }; @@ -41,8 +46,7 @@ function send_filters() { return response.json(); }) .then(data => { - document.getElementById('data').value = JSON.stringify(data); - + console.log(JSON.stringify(data)) }) .catch(error => { console.error('Error:', error); @@ -61,16 +65,13 @@ function send_filters() { let btn_ok = document.getElementById("btn_ok"); btn_ok.addEventListener('click', () => { send_filters(); - // tg.sendData(tg.initDataUnsafe.user.id.toString()); - - //tg.close(); + // tg.close(); }); -//Слайдер дюрации -var durationSlider = document.getElementById('duration-slider'); -noUiSlider.create(durationSlider, { +var redemptionSlider = document.getElementById('redemption-slider'); +noUiSlider.create(redemptionSlider, { start: [3, 36], connect: true, range: { @@ -79,19 +80,19 @@ noUiSlider.create(durationSlider, { } }); -var durationFrom = document.getElementById('duration-from'); -var durationTo = document.getElementById('duration-to'); -var durations = [durationFrom, durationTo]; +var redemptionFrom = document.getElementById('redemption-from'); +var redemptionTo = document.getElementById('redemption-to'); +var redemptions = [redemptionFrom, redemptionTo]; -durationSlider.noUiSlider.on('update', function (values, handle) { - durations[handle].value = Math.round(values[handle]); +redemptionSlider.noUiSlider.on('update', function (values, handle) { + redemptions[handle].value = Math.round(values[handle]); }); -durationFrom.addEventListener('change', function () { - durationSlider.noUiSlider.set([null, this.value]); +redemptionFrom.addEventListener('change', function () { + redemptionSlider.noUiSlider.set([null, this.value]); }); -durationTo.addEventListener('change', function () { - durationSlider.noUiSlider.set([null, this.value]); +redemptionTo.addEventListener('change', function () { + redemptionSlider.noUiSlider.set([null, this.value]); }); diff --git a/html/style.css b/html/style.css index 232ebb6..8a873ac 100644 --- a/html/style.css +++ b/html/style.css @@ -98,11 +98,20 @@ input[type='text'] { margin-bottom: 20px; } -input[type='checkbox']{ +input[type='checkbox'] { transform: scale(2); margin: 10px 10px; } +input[type='radio'] { + transform: scale(1.5); + margin: 15px 40px; +} + +.order-label { + margin-left: -30px; +} + .slider { height: 10px; margin: 10px 0px 0px; diff --git a/src/bonds/getter.py b/src/bonds/getter.py index 5a806bb..66c6536 100644 --- a/src/bonds/getter.py +++ b/src/bonds/getter.py @@ -13,45 +13,79 @@ def columns(self): return self.__columns__ def __needed__(self, filters, paper): - for i, column in enumerate(self.__columns__): - if column == 'IS_QUALIFIED_INVESTORS': - value = int(paper[i]) - if value == 1 and not filters.is_qual: - return False - elif column == 'HIGH_RISK': - value = int(paper[i]) - if value == 1: - return False - + offer = paper['OFFERDATE'] + if offer and not filters.is_offer: + return False return True + # def __sort__(self, filters, papers): + # key = filters.sort.key + # reverse = filters.sort.order == "dsc" + # comp = lambda paper: float(0.0 if not paper[key] else paper[key]) + # return sorted(papers, key=comp, reverse=reverse) - def __filter_paper__(self, paper): - filtered_paper = {} + def __convert__(self, paper): + converted = {} for i, column in enumerate(self.__columns__): - filtered_paper[column] = paper[i] - return filtered_paper + converted[column] = paper[i] + return converted + + def __filter__(self, filters, papers): + filtered = [] + for paper in papers: + converted = self.__convert__(paper) + if self.__needed__(filters, converted): + filtered.append(converted) + return filtered + + # "rates.cursor": { + # "columns": [ + # "INDEX", + # "TOTAL", + # "PAGESIZE" + # ], + # "data": [ + # [ + # 0, + # 214, + # 100 + # ] + # ] + # } + def __get_total__(self, json): + for i, key in enumerate(json["rates.cursor"]["columns"]): + if key == 'TOTAL': + return int(json["rates.cursor"]["data"][0][i]) + + raise Exception(f'Cannot get TOTAL from {json}') def get(self, filters): logger.info(f'BondsGetter:get {filters}') - request = BondsRequest() - # https://iss.moex.com/iss/apps/infogrid/emission/rates.json?lang=ru&iss.meta=on&sort_order=dsc&sort_column=YIELDATWAP&start=0&limit=100&coupon_frequency=4,6,12&columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS,INN&coupon_percent=20,50&duration=90,1080&sec_type=stock_corporate_bond¤cyid=rub - url = str(request.lang().meta(False).sort().scroll().period(filters.period).columns().coupons(filters.coupons.fr, filters.coupons.to).duration(filters.duration.fr, filters.duration.to).sec_type().currencyid()) - logger.info(f'Request: {url}') + total = 1000 + offset = 0 - response = requests.get(url, timeout=5) + filtered_papers = [] + while offset < total: + scroll = min(100, total - offset) + logger.info(f'Scrolling from {offset} to {total} by {scroll}') - if response.status_code != 200: - logger.error(f'Cannot get MOEX request (code={response.status_code}): {response.reason}') - return None + # https://iss.moex.com/iss/apps/infogrid/emission/rates.json?lang=ru&iss.meta=off&sort_order=dsc&sort_column=YIELDATWAP&start=0&limit=1000&coupon_frequency=4,12&redemption=60,1080&coupon_percent=20,50&columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS&sec_type=stock_corporate_bond,stock_exchange_bond¤cyid=rub&high_risk=0 + url = str(BondsRequest().lang().meta(False).sort(filters.sort.order, filters.sort.key).scroll(offset, scroll).period(filters.period).redemption(filters.redemption.fr, filters.redemption.to).coupons(filters.coupons.fr, filters.coupons.to).qual(filters.is_qual).amortization(filters.is_amort).columns().sec_type().currencyid().high_risk(False).listing([1,2])) + logger.info(f'Request: {url}') - json = response.json() - # metadata = json["rates"]["metadata"] - self.__columns__ = json["rates"]["columns"] + response = requests.get(url, timeout=5) - filtered_papers = [] - for paper in json["rates"]["data"]: - if self.__needed__(filters, paper): - filtered_papers.append(self.__filter_paper__(paper)) + if response.status_code != 200: + logger.error(f'Cannot get MOEX request (code={response.status_code}): {response.reason}') + return None + + json = response.json() + total = self.__get_total__(json) + offset = offset + min(scroll, total-offset) + + self.__columns__ = json["rates"]["columns"] + filtered_papers.extend(self.__filter__(filters, json["rates"]["data"])) + + logger.info(f'{filtered_papers}') return filtered_papers diff --git a/src/bonds/request.py b/src/bonds/request.py index 1c6b2a1..95c1d9e 100644 --- a/src/bonds/request.py +++ b/src/bonds/request.py @@ -1,25 +1,26 @@ # https://iss.moex.com/iss/apps/infogrid/emission/rates.json? # lang=ru& -# iss.meta=on& -# sort_order=asc& -# sort_column=SECID& -# start=0& -# limit=100& -# coupon_frequency=12,4 -# columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS,INN& -# coupon_percent=19.96,35& -# duration=1,1497& -# sec_type=stock_corporate_bond& -# currencyid=rub +# iss.meta=off& +# sort_order=dsc& +# sort_column=YIELDATWAP& +# start=0&limit=100& +# coupon_frequency=4,12& +# redemption=60,1080& +# coupon_percent=20,50& +# columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS& +# sec_type=stock_corporate_bond,stock_exchange_bond& +# currencyid=rub& +# high_risk=0 + # колонки # https://iss.moex.com/iss/apps/infogrid/emission/columns.json?lang=ru&iss.meta=off - class BondsRequest: __req__ = None __columns__ = [ 'SECID', + 'INITIALFACEVALUE', 'SHORTNAME', 'ISIN', 'FACEVALUE', @@ -40,7 +41,7 @@ class BondsRequest: 'TYPENAME', 'DURATION', 'IS_QUALIFIED_INVESTORS', - 'INN' + 'LISTLEVEL' ] @property @@ -79,21 +80,42 @@ def columns(self, optional = None): self.__req__ += f',{optional}' return self + def amortization(self, include=True): + if not include: + self.__req__ += f'amortization=0&' + return self + + def qual(self, include=True): + if not include: + self.__req__ += f'qi=0&' + return self + def coupons(self, fr, to): self.__req__ += f'coupon_percent={fr},{to}&' return self - def duration(self, fr, to): - self.__req__ += f'duration={fr*30},{to*30}&' + def redemption(self, fr, to): + self.__req__ += f'redemption={fr*30},{to*30}&' return self def sec_type(self): - self.__req__ += f'sec_type=stock_corporate_bond&' + self.__req__ += f'sec_type=stock_corporate_bond,stock_exchange_bond&' return self def currencyid(self, value='rub'): self.__req__ += f'currencyid={value}&' return self + def high_risk(self, value=False): + if not value: + self.__req__ += f'high_risk={0}&' + return self + + def listing(self, listname=[1,2,3]): + # 3 excluded + val = ','.join(map(str, listname)) + self.__req__ += f'listname={val}&' + return self + def __str__(self): return self.__req__[:-1] diff --git a/src/bot.py b/src/bot.py index 52e7b3c..7904794 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,55 +1,5 @@ from config import config from aiogram import Bot -from aiogram.enums import ParseMode -from aiogram.types import LinkPreviewOptions -from logger import * -import queue - -logger = logging.getLogger("Jbond") - -bot = Bot(token = config['bot']['token']) - -messages_queue = queue.Queue() - -class SendMessageTask(object): - def __init__(self, chat_id, paper): - self.chat_id = chat_id - self.paper = paper - - async def __call__(self): - logger.info(f'Send message to {self.chat_id}') - try: - - isin = self.paper["ISIN"] - name = self.paper["NAME"] - duration = self.paper["DURATION"] - coupon = self.paper["COUPONPERCENT"] - coupon_date = self.paper["COUPONDATE"] - yieldatwap = self.paper["YIELDATWAP"] - couponlength = self.paper["COUPONLENGTH"] - price_percent = self.paper["PRICE"] - price_rub = self.paper["PRICE_RUB"] - qual = "да" if (int(self.paper["IS_QUALIFIED_INVESTORS"]) == 0) else "нет" - - - text = f'''📌 Имя:\t{name} -🔎 ISIN:\t{isin} -💲 Цена:\t{price_percent}% ({price_rub}₽ ) -📈 Доходность:\t{yieldatwap}% -📆 Купон:\t{coupon}% (раз в {round(couponlength/30)} мес.), ближайший {coupon_date} -⏳ Дюрация:\t{duration} дней ({round(duration/30, 1)} мес) -🐹 Доступно для неквалов:\t{qual} -📞 Оферта:\t...''' - - await bot.send_message(chat_id=self.chat_id, text=text, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True)) - except Exception as e: - logger.error(f'Cannot send bot message len={len(text)}: {e}\n{text}') - - - -class MessagePack(list): - async def __call__(self): - for task in self: - await task() \ No newline at end of file +bot = Bot(token = config['bot']['token']) \ No newline at end of file diff --git a/src/handlers.py b/src/handlers.py new file mode 100644 index 0000000..03cee7e --- /dev/null +++ b/src/handlers.py @@ -0,0 +1,59 @@ +from aiogram import Router, F +from aiogram.filters import CommandStart, Command +from aiogram.types import Message, KeyboardButton, WebAppInfo, CallbackQuery +from aiogram.utils.keyboard import ReplyKeyboardBuilder +from aiogram.exceptions import TelegramBadRequest +from config import config +from bot import bot +from logger import * +from messages import * + +base_router = Router() + +logger = logging.getLogger("Router") + +@base_router.message(CommandStart()) +async def handle_start(message: Message): + logger.info(f'Command /start from {message.from_user.full_name}') + address = config["server"]["address"] + port = config["server"]["port"] + webAppInfo = WebAppInfo(url = f"https://{address}:{port}") + builder = ReplyKeyboardBuilder() + builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) + await message.answer(text = f'Поехали', reply_markup = builder.as_markup()) + +@base_router.message(Command('filters')) +async def cmd_filters(message: Message): + logger.info(f'Command /filters from {message.from_user.full_name}') + address = config["server"]["address"] + port = config["server"]["port"] + webAppInfo = WebAppInfo(url = f"https://{address}:{port}") + builder = ReplyKeyboardBuilder() + builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) + await message.answer(text = 'Хуильтры', reply_markup = builder.as_markup()) + +@base_router.message(Command('clear')) +async def cmd_clear(message: Message): + logger.info(f'Command /clear from {message.from_user.full_name}') + try: + # Все сообщения, начиная с текущего и до первого (message_id = 0) + for i in range(message.message_id, 1, -1): + await bot.delete_message(message.from_user.id, i) + except TelegramBadRequest as ex: + # Если сообщение не найдено (уже удалено или не существует), + # код ошибки будет "Bad Request: message to delete not found" + if ex.message == "Bad Request: message to delete not found": + logger.info(f'History has been cleared') + + +@base_router.message(lambda message: message.web_app_data) +async def handle_web_app_data(message: Message): + logger.info(f'{message.web_app_data}') + await message.answer(f'{message.web_app_data}') + + +@base_router.callback_query(F.data == "more") +async def handle_more(callback: CallbackQuery): + if not pending_messages.empty(): + messages_queue.put(pending_messages.get()) + await callback.answer('Еще 10 облигаций -->') diff --git a/src/handlers/start.py b/src/handlers/start.py deleted file mode 100644 index ffecaae..0000000 --- a/src/handlers/start.py +++ /dev/null @@ -1,30 +0,0 @@ -from aiogram import Router, F -from aiogram.filters import CommandStart, Command -from aiogram.types import Message, KeyboardButton, WebAppInfo -from aiogram.utils.keyboard import ReplyKeyboardBuilder -from config import config -from logger import * - -start_router = Router() - -logger = logging.getLogger("Start") - -@start_router.message(CommandStart()) -async def handle_start(message: Message): - logger.info(f'Command /start from {message.from_user.full_name}') - address = config["server"]["address"] - port = config["server"]["port"] - webAppInfo = WebAppInfo(url = f"https://{address}:{port}") - builder = ReplyKeyboardBuilder() - builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) - await message.answer(text = f'Поехали', reply_markup = builder.as_markup()) - -@start_router.message(Command('filters')) -async def cmd_filters(message: Message): - logger.info(f'Command /filters from {message.from_user.full_name}') - address = config["server"]["address"] - port = config["server"]["port"] - webAppInfo = WebAppInfo(url = f"https://{address}:{port}") - builder = ReplyKeyboardBuilder() - builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) - await message.answer(text = 'Хуильтры', reply_markup = builder.as_markup()) diff --git a/src/handlers/web_app.py b/src/handlers/web_app.py deleted file mode 100644 index 8a18dcc..0000000 --- a/src/handlers/web_app.py +++ /dev/null @@ -1,12 +0,0 @@ -from aiogram import Router -from aiogram.types import Message -from logger import * - -app_router = Router() - -logger = logging.getLogger("App") - -@app_router.message(lambda message: message.web_app_data) -async def handle_web_app_data(message: Message): - logger.info(f'{message.web_app_data}') - await message.answer(f'{message.web_app_data}') diff --git a/src/http_server.py b/src/http_server.py index b5be6f4..17dd33e 100644 --- a/src/http_server.py +++ b/src/http_server.py @@ -9,7 +9,7 @@ from bonds.getter import BondsGetter from logger import * from bot import * - +from messages import * logger = logging.getLogger("Http") @@ -30,7 +30,7 @@ def __init__(self, *args, **kwargs): def __send_response__(self, code, content_type=None, data=None): self.send_response(code) - self.send_header('Content-Length', (len(data) if data else 0)) + self.send_header('Content-Length', int(len(data) if data else 0)) self.send_header('Connection', 'close') if content_type: self.send_header('Content-Type', content_type) @@ -53,7 +53,7 @@ def do_POST(self): self.__send_response__(200, 'application/json', bytes(json.dumps(json_bonds, indent=0), 'utf-8')) - message_pack = MessagePack() + message_pack = MessagePack(filters.chat_id) for i, paper in enumerate(json_bonds): message_pack.append(SendMessageTask(filters.chat_id, paper)) diff --git a/src/main.py b/src/main.py index 947a8c9..5f7e91b 100644 --- a/src/main.py +++ b/src/main.py @@ -3,9 +3,9 @@ from logger import * from config import config from http_server import HttpServer -from handlers.start import start_router -from handlers.web_app import app_router +from handlers import base_router from bot import * +from messages import * from aiogram import Bot, Dispatcher from aiogram.fsm.storage.memory import MemoryStorage @@ -13,24 +13,16 @@ logger = logging.getLogger("Main") - -async def send_messages(): - while not messages_queue.empty(): - message_pack = messages_queue.get() - await message_pack() - - async def main(): try: httpd = HttpServer(port = config["server"]["port"]) scheduler = AsyncIOScheduler(timezone='Europe/Moscow') - scheduler.add_job(send_messages, 'interval', seconds=3) + scheduler.add_job(send_message_pack, 'interval', seconds=3) scheduler.start() dispatcher = Dispatcher(storage = MemoryStorage()) - dispatcher.include_router(start_router) - dispatcher.include_router(app_router) + dispatcher.include_router(base_router) await bot.delete_webhook(drop_pending_updates = True, request_timeout = 10) await dispatcher.start_polling(bot) diff --git a/src/messages.py b/src/messages.py new file mode 100644 index 0000000..7dfff9a --- /dev/null +++ b/src/messages.py @@ -0,0 +1,99 @@ +import queue +from datetime import datetime +from aiogram.enums import ParseMode +from aiogram.types import LinkPreviewOptions, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from bot import bot +from logger import * + +logger = logging.getLogger("Messages") + +messages_queue = queue.Queue() +pending_messages = queue.Queue() + +async def send_message_pack(): + while not messages_queue.empty(): + message_pack = messages_queue.get() + await message_pack() + + +class SendMessageTask(object): + def __init__(self, chat_id, paper): + self.chat_id = chat_id + self.paper = paper + + def get_int(self, key): + value = self.paper[key] + return int(0 if not value else value) + + def get_float(self, key): + value = self.paper[key] + return float(0.0 if not value else value) + + async def __call__(self): + logger.info(f'Send message to {self.chat_id}') + try: + isin = self.paper["ISIN"] + name = self.paper["SHORTNAME"] + nominal = self.get_float("FACEVALUE") + redemption = self.get_int("DAYSTOREDEMPTION") + # duration = self.get_int("DURATION") + coupon = self.get_float("COUPONPERCENT") + yieldatwap = self.get_float("YIELDATWAP") + couponlength = self.get_int("COUPONLENGTH") + price_percent = self.get_float("PRICE") + price_rub = self.get_float("PRICE_RUB") + qual = "да" if self.get_int("IS_QUALIFIED_INVESTORS") == 0 else "нет" + offer = self.paper['OFFERDATE'] if self.paper['OFFERDATE'] else "нет" + + text = f'''📌 Имя:\t{name} +🔎 ISIN:\t{isin} +💲 Цена:\t{price_percent}% ({price_rub}₽ / {nominal}₽) +📈 Доходность:\t{yieldatwap}% +📆 Купон:\t{coupon}% (раз в {round(couponlength/30)} мес.) +⏳ До погашения:\t{round(redemption/30, 1)} мес ({redemption} дней) +🐹 Доступно для неквалов:\t{qual} +📞 Оферта:\t{offer}''' + +# ⏰ Дюрация:\t{round(duration/30, 1)} мес ({duration} дней) + + await bot.send_message(chat_id=self.chat_id, text=text, disable_notification=True, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True)) + except Exception as e: + logger.error(f'Cannot send bot message len={len(text)}: {e}\n{text}') + + + +class MessagePack: + messages = [] + offset = 0 + + def __init__(self, chat_id): + self.chat_id = chat_id + + def append(self, message: SendMessageTask): + self.messages.append(message) + + def __len__(self): + return len(self.messages) + + async def __call__(self): + formatted_datetime = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + if self.offset == 0: + await bot.send_message(chat_id=self.chat_id, text=f'===== Результаты от {formatted_datetime} =====') + + for i, task in enumerate(self.messages): + if i == 10: + while not pending_messages.empty(): + pending_messages.get() + + pending = MessagePack(self.chat_id) + pending.messages = self.messages[10:] + pending.offset = self.offset + 10 + pending_messages.put(pending) + + builder = InlineKeyboardBuilder() + builder.add(InlineKeyboardButton(text="Показать еще", callback_data="more")) + await bot.send_message(chat_id=self.chat_id, text=f'❗ Показано только 10 из {len(self.messages)} результатов', disable_notification=True, reply_markup=builder.as_markup()) + return + await task() \ No newline at end of file From 33e65a5fdd286d16718e61b64b57c933d4020e9d Mon Sep 17 00:00:00 2001 From: andrevis Date: Sun, 6 Jul 2025 16:22:58 +0300 Subject: [PATCH 03/10] defaults --- .github/workflows/ci.yml | 2 +- html/index.html | 29 ++++++++++++--- html/script.js | 37 +++++++++++++++++-- src/bonds/defaults.py | 34 ++++++++++++++++++ src/bonds/getter.py | 44 +++++++++++------------ src/bonds/request.py | 5 ++- src/handlers.py | 2 +- src/http_server.py | 38 +++++++++++--------- src/messages.py | 78 +++++++++++++++++++++++++++------------- src/tables.txt | 35 ------------------ 10 files changed, 192 insertions(+), 112 deletions(-) create mode 100644 src/bonds/defaults.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0bef12..5ca18e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: python3 -m ensurepip python3 -m venv /opt/certbot/ python3 -m pip install --upgrade pip - pip3 install aiogram apscheduler tomli certbot py-postgresql + pip3 install aiogram pandas apscheduler tomli certbot py-postgresql # - name: Install some html stuff # run: | diff --git a/html/index.html b/html/index.html index 78c3700..2123bbe 100644 --- a/html/index.html +++ b/html/index.html @@ -15,7 +15,7 @@
- +
@@ -57,7 +57,21 @@
- + +
+
+ +
+ - +
+ +
+
+
+
+ +
+
@@ -97,17 +111,22 @@
- +
- +
- +
+
+ + +
+
diff --git a/html/script.js b/html/script.js index 8fcc42a..fb1fa3f 100644 --- a/html/script.js +++ b/html/script.js @@ -10,8 +10,8 @@ function send_filters() { is_amort: document.getElementById('is_amort').checked, is_offer: document.getElementById('is_offer').checked, redemption: { - fr: parseInt(document.getElementById('redemption-from').value), - to: parseInt(document.getElementById('redemption-to').value), + fr: parseInt(document.getElementById('redemption-from').value) * 30, + to: parseInt(document.getElementById('redemption-to').value) * 30, }, profit: { fr: parseInt(document.getElementById('profit-from').value), @@ -21,6 +21,10 @@ function send_filters() { fr: parseInt(document.getElementById('coupons-from').value), to: parseInt(document.getElementById('coupons-to').value), }, + duration: { + fr: parseInt(document.getElementById('duration-from').value) * 30, + to: parseInt(document.getElementById('duration-to').value) * 30, + }, sort: { order: Array.from(document.getElementsByClassName("order")).filter((elem) => (elem.checked)).map((elem) => (elem.value)).toString(), key: Array.from(document.getElementsByClassName("sort-by")).filter((elem) => (elem.checked)).map((elem) => (elem.value)).toString(), @@ -153,6 +157,35 @@ couponsTo.addEventListener('change', function () { }); +//Слайдер дюрации +var durationSlider = document.getElementById('duration-slider'); +noUiSlider.create(durationSlider, { + start: [0, 50], + connect: true, + range: { + 'min': 0, + 'max': 50 + } +}); + +var durationFrom = document.getElementById('duration-from'); +var durationTo = document.getElementById('duration-to'); +var duration = [durationFrom, durationTo]; + +durationSlider.noUiSlider.on('update', function (values, handle) { + duration[handle].value = Math.round(values[handle]*2)/2; +}); + +durationFrom.addEventListener('change', function () { + durationSlider.noUiSlider.set([null, this.value]); +}); + +durationTo.addEventListener('change', function () { + durationSlider.noUiSlider.set([null, this.value]); +}); + + + //Слайдер рейтинга var ratings = ['BB-','BB','BB+','BBB-','BBB','BBB+','A-','A','A+','AA-','AA','AA+','AAA-','AAA']; var ratingFormat = { diff --git a/src/bonds/defaults.py b/src/bonds/defaults.py new file mode 100644 index 0000000..2999b24 --- /dev/null +++ b/src/bonds/defaults.py @@ -0,0 +1,34 @@ +import requests +import pandas +import io +from logger import * + +logger = logging.getLogger("Defaults") + +class DefaultsGetter: + + @staticmethod + def get(isin): + headers = { + 'Accept': '*/*', + 'Accept-Encoding': 'gzip, deflate, br, zstd', + 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', + 'Connection': 'close', + 'DNT': '1', + 'Host': 'web.moex.com', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' + } + + url = "https://web.moex.com/moex-web-icdb-api/api/v1/export/site-defaults/xlsx" + response = requests.get(url, timeout=5, headers=headers) + if response.status_code != 200: + logger.error(f'Cannot get MOEX defaults (code={response.status_code}): {response.reason}') + return None + + excel_data = pandas.read_excel(io.BytesIO(response.content), usecols=['ISIN', 'Состояние', 'Плановая дата']) + + records = excel_data.to_dict(orient='records') + records = list(filter(lambda record : record['ISIN'] == isin, records)) + records = list(map(lambda record : record['Плановая дата'], records)) + return records diff --git a/src/bonds/getter.py b/src/bonds/getter.py index 66c6536..2498227 100644 --- a/src/bonds/getter.py +++ b/src/bonds/getter.py @@ -2,6 +2,7 @@ from logger import * from bonds.request import BondsRequest import json +from operator import attrgetter logger = logging.getLogger("Bond") @@ -16,41 +17,38 @@ def __needed__(self, filters, paper): offer = paper['OFFERDATE'] if offer and not filters.is_offer: return False + + if paper[filters.sort.key] == 0: + return False + return True - # def __sort__(self, filters, papers): - # key = filters.sort.key - # reverse = filters.sort.order == "dsc" - # comp = lambda paper: float(0.0 if not paper[key] else paper[key]) - # return sorted(papers, key=comp, reverse=reverse) + def __sort__(self, key, order, papers): + reverse = (order == "desc") + logger.info(f'Sorting {order} by {key} - reverse={reverse}') + return sorted(papers, key=lambda paper: paper[key], reverse=reverse) - def __convert__(self, paper): + def __convert__(self, filters, paper): converted = {} for i, column in enumerate(self.__columns__): converted[column] = paper[i] + + if not converted[filters.sort.key]: + converted[filters.sort.key] = 0 + return converted def __filter__(self, filters, papers): filtered = [] for paper in papers: - converted = self.__convert__(paper) + converted = self.__convert__(filters, paper) if self.__needed__(filters, converted): filtered.append(converted) return filtered - # "rates.cursor": { - # "columns": [ - # "INDEX", - # "TOTAL", - # "PAGESIZE" - # ], - # "data": [ - # [ - # 0, - # 214, - # 100 - # ] - # ] + # "rates.cursor": { + # "columns": [ "INDEX", "TOTAL", "PAGESIZE" ], + # "data": [[ 0, 214, 100 ]] # } def __get_total__(self, json): for i, key in enumerate(json["rates.cursor"]["columns"]): @@ -75,17 +73,15 @@ def get(self, filters): logger.info(f'Request: {url}') response = requests.get(url, timeout=5) - if response.status_code != 200: logger.error(f'Cannot get MOEX request (code={response.status_code}): {response.reason}') return None json = response.json() total = self.__get_total__(json) - offset = offset + min(scroll, total-offset) + offset += min(scroll, total-offset) self.__columns__ = json["rates"]["columns"] filtered_papers.extend(self.__filter__(filters, json["rates"]["data"])) - logger.info(f'{filtered_papers}') - return filtered_papers + return self.__sort__(filters.sort.key, filters.sort.order, filtered_papers) diff --git a/src/bonds/request.py b/src/bonds/request.py index 95c1d9e..9d7e121 100644 --- a/src/bonds/request.py +++ b/src/bonds/request.py @@ -60,7 +60,7 @@ def meta(self, meta = False): self.__req__ += f'iss.meta={val}&' return self - def sort(self, odred = 'dsc', col = 'YIELDATWAP'): + def sort(self, odred = 'asc', col = 'YIELDATWAP'): self.__req__ += f'sort_order={odred}&sort_column={col}&' return self @@ -95,7 +95,7 @@ def coupons(self, fr, to): return self def redemption(self, fr, to): - self.__req__ += f'redemption={fr*30},{to*30}&' + self.__req__ += f'redemption={fr},{to}&' return self def sec_type(self): @@ -112,7 +112,6 @@ def high_risk(self, value=False): return self def listing(self, listname=[1,2,3]): - # 3 excluded val = ','.join(map(str, listname)) self.__req__ += f'listname={val}&' return self diff --git a/src/handlers.py b/src/handlers.py index 03cee7e..a6af8a5 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -56,4 +56,4 @@ async def handle_web_app_data(message: Message): async def handle_more(callback: CallbackQuery): if not pending_messages.empty(): messages_queue.put(pending_messages.get()) - await callback.answer('Еще 10 облигаций -->') + await callback.answer('Еще 5 облигаций -->') diff --git a/src/http_server.py b/src/http_server.py index 17dd33e..6736ad3 100644 --- a/src/http_server.py +++ b/src/http_server.py @@ -39,30 +39,34 @@ def __send_response__(self, code, content_type=None, data=None): self.wfile.write(data) def do_POST(self): - logger.info(f'Incoming POST {self.path} from {self.client_address}') + try: + logger.info(f'Incoming POST {self.path} from {self.client_address}') - content_length = int(self.headers['Content-Length']) + content_length = int(self.headers['Content-Length']) - if self.path == '/filters' and content_length > 0: - filters = parse_filters(self.rfile.read(content_length)) + if self.path == '/filters' and content_length > 0: + filters = parse_filters(self.rfile.read(content_length)) - json_bonds = self.__bonds_getter__.get(filters) - if not json_bonds: - self.__send_response__(500) - return + json_bonds = self.__bonds_getter__.get(filters) + if not json_bonds: + self.__send_response__(500) + return - self.__send_response__(200, 'application/json', bytes(json.dumps(json_bonds, indent=0), 'utf-8')) + self.__send_response__(200, 'application/json', bytes(json.dumps(json_bonds, indent=0), 'utf-8')) - message_pack = MessagePack(filters.chat_id) - for i, paper in enumerate(json_bonds): - message_pack.append(SendMessageTask(filters.chat_id, paper)) + message_pack = MessagePack(filters.chat_id, filters.sort.key) + for paper in json_bonds: + message_pack.append(SendMessageTask(filters.chat_id, paper)) - if len(message_pack) > 0: - messages_queue.put(message_pack) + if len(message_pack) > 0: + messages_queue.put(message_pack) - else: - self.__send_response__(400) - return + else: + self.__send_response__(400) + return + except Exception as e: + logger.error(f'POST {self.path} exception: {e}') + self.__send_response__(500) def do_GET(self): diff --git a/src/messages.py b/src/messages.py index 7dfff9a..5ac47df 100644 --- a/src/messages.py +++ b/src/messages.py @@ -3,9 +3,10 @@ from aiogram.enums import ParseMode from aiogram.types import LinkPreviewOptions, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder - +import asyncio from bot import bot from logger import * +from bonds.defaults import DefaultsGetter logger = logging.getLogger("Messages") @@ -31,6 +32,15 @@ def get_float(self, key): value = self.paper[key] return float(0.0 if not value else value) + def get_defaults(self, isin): + defaults = DefaultsGetter.get(isin) + if defaults == None: + return "неизвестно" + elif len(defaults) == 0: + return "не было" + else: + return ', '.join(defaults) + async def __call__(self): logger.info(f'Send message to {self.chat_id}') try: @@ -38,7 +48,7 @@ async def __call__(self): name = self.paper["SHORTNAME"] nominal = self.get_float("FACEVALUE") redemption = self.get_int("DAYSTOREDEMPTION") - # duration = self.get_int("DURATION") + duration = self.get_int("DURATION") coupon = self.get_float("COUPONPERCENT") yieldatwap = self.get_float("YIELDATWAP") couponlength = self.get_int("COUPONLENGTH") @@ -46,17 +56,19 @@ async def __call__(self): price_rub = self.get_float("PRICE_RUB") qual = "да" if self.get_int("IS_QUALIFIED_INVESTORS") == 0 else "нет" offer = self.paper['OFFERDATE'] if self.paper['OFFERDATE'] else "нет" + defaults = self.get_defaults(isin) + matdate = self.paper["MATDATE"] text = f'''📌 Имя:\t{name} 🔎 ISIN:\t{isin} 💲 Цена:\t{price_percent}% ({price_rub}₽ / {nominal}₽) 📈 Доходность:\t{yieldatwap}% 📆 Купон:\t{coupon}% (раз в {round(couponlength/30)} мес.) -⏳ До погашения:\t{round(redemption/30, 1)} мес ({redemption} дней) +⏳ Погашение:\t{matdate} ({redemption} д.) 🐹 Доступно для неквалов:\t{qual} -📞 Оферта:\t{offer}''' - -# ⏰ Дюрация:\t{round(duration/30, 1)} мес ({duration} дней) +📞 Оферта:\t{offer} +❌ Дефолты:\t{defaults} +⏰ Дюрация:\t{round(duration/30, 1)} мес ({duration} дней)''' await bot.send_message(chat_id=self.chat_id, text=text, disable_notification=True, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True)) except Exception as e: @@ -65,11 +77,26 @@ async def __call__(self): class MessagePack: - messages = [] - offset = 0 + shift = 5 - def __init__(self, chat_id): + def __init__(self, chat_id, sortby): + self.messages = [] self.chat_id = chat_id + self.sortby = sortby + self.offset = 0 + self.idx = 0 + + def __iter__(self): + self.idx = 0 + return self + + def __next__(self): + self.idx += 1 + try: + return self.messages[self.idx-1] + except IndexError: + self.idx = 0 + raise StopIteration # Done iterating. def append(self, message: SendMessageTask): self.messages.append(message) @@ -77,23 +104,26 @@ def append(self, message: SendMessageTask): def __len__(self): return len(self.messages) + async def __pending__(self): + while not pending_messages.empty(): + pending_messages.get() + + pending = MessagePack(self.chat_id, self.sortby) + pending.messages = self.messages[self.shift:] + pending.offset = self.offset + self.shift + pending_messages.put(pending) + + builder = InlineKeyboardBuilder() + builder.add(InlineKeyboardButton(text="Показать еще", callback_data="more")) + await bot.send_message(chat_id=self.chat_id, text=f'❗ Показано только {self.shift} из {len(self.messages)} результатов', disable_notification=True, reply_markup=builder.as_markup()) + async def __call__(self): formatted_datetime = datetime.now().strftime("%d-%m-%Y %H:%M:%S") if self.offset == 0: - await bot.send_message(chat_id=self.chat_id, text=f'===== Результаты от {formatted_datetime} =====') - - for i, task in enumerate(self.messages): - if i == 10: - while not pending_messages.empty(): - pending_messages.get() - - pending = MessagePack(self.chat_id) - pending.messages = self.messages[10:] - pending.offset = self.offset + 10 - pending_messages.put(pending) + await bot.send_message(chat_id=self.chat_id, text=f'=== {formatted_datetime} ({self.shift} из {len(self.messages)})') - builder = InlineKeyboardBuilder() - builder.add(InlineKeyboardButton(text="Показать еще", callback_data="more")) - await bot.send_message(chat_id=self.chat_id, text=f'❗ Показано только 10 из {len(self.messages)} результатов', disable_notification=True, reply_markup=builder.as_markup()) + for i, job in enumerate(self.messages): + if i == self.shift: + await self.__pending__() return - await task() \ No newline at end of file + await job() diff --git a/src/tables.txt b/src/tables.txt index bdcb66e..e14252f 100644 --- a/src/tables.txt +++ b/src/tables.txt @@ -1,44 +1,9 @@ -# DIVIDENTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-dividend-yields/xlsx' - DEFOLTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-defaults/xlsx' -r - - Погашение и амортизация: https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=amortizations,amortizations.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 Оферты https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=offers,offers.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 - -Купоны -https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=coupons,coupons.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 - -облигации -https://iss.moex.com/iss/apps/infogrid/stock/rates.json?_=1751043561209&lang=ru&iss.meta=off&sort_order=asc&sort_column=SECID&start=0&limit=100&columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,ISSUESIZE,MATDATE,COUPONFREQUENCY,COUPONPERCENT,PRICE,PRICE_RUB¤cyid=rub&faceunit=rub&bg=&sec_type=stock_corporate_bond - - -https://iss.moex.com/iss/engines/stock/markets/index/securities.json?iss.meta=off&iss.json=extended&iss.only=marketdata&marketdata.columns=SECID%2CCURRENTVALUE%2CLASTCHANGEPRC&credentials=credentials&securities=IMOEX%2CRGBITR%2CRUCBTRNS%2CRUMBTRNS%2CRUCBTR3YNS%2CRUCBTR5YNS - -Конкретная бумага -https://iss.moex.com/iss/securities/RU000A0JQAM6.json?iss.json=extended&iss.meta=off -https://www.moex.com/ru/issue.aspx?code=RU000A0NNE69 - -ВСе бумаги, но не вся инфа -https://iss.moex.com/iss/engines/stock/markets/shares/boardgroups/57/securities.json - - -ВСе бумаги с фильтрма -https://www.moex.com/s2644#/?currencyid%5B%5D='rub'&sec_type%5B%5D='stock_corporate_bond'&duration%5B%5D=1,1497&coupon_percent%5B%5D=19.96,35&columns%5B%5D='SECID','SHORTNAME','ISIN','FACEVALUE','FACEUNIT','ISSUESIZE','MATDATE','COUPONFREQUENCY','COUPONPERCENT','OFFERDATE','DAYSTOREDEMPTION','SECSUBTYPE','EMITENTNAME','YIELDATWAP','COUPONDATE','DISCOUNT3','DISCOUNT2','DISCOUNT1','PRICE_RUB','PRICE','REPLBOND','ISSUEDATE','COUPONLENGTH','COUPONDAYSREMAIN','COUPONDAYSPASSED','TYPENAME','DURATION','IS_QUALIFIED_INVESTORS' - - -api -https://iss.moex.com/iss/reference/ - -# https://iss.moex.com/iss/engines/currency/markets/selt/boardgroups/13/securities.json -# https://iss.moex.com/iss/engines/stock/markets/index/securities.js - - -# инфа https://iss.moex.com/iss/engines/stock/markets/bonds \ No newline at end of file From e276ed54b30c2ee30382a41bb95c06ed59df2b26 Mon Sep 17 00:00:00 2001 From: andrevis Date: Sun, 6 Jul 2025 16:24:17 +0300 Subject: [PATCH 04/10] defaults --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 50744f1..b9d88fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ token *.pem *.toml +*.log +*.log.* # Byte-compiled / optimized / DLL files __pycache__/ @@ -60,7 +62,7 @@ cover/ *.pot # Django stuff: -*.log + local_settings.py db.sqlite3 db.sqlite3-journal From 08192a324d9d5c7ce021732fb20223b18e4cf42f Mon Sep 17 00:00:00 2001 From: andrevis Date: Sun, 6 Jul 2025 21:39:42 +0300 Subject: [PATCH 05/10] fixup --- html/index.html | 38 +++--- html/script.js | 290 ++++++++++++++++++++++++-------------------- html/style.css | 13 +- src/bonds/getter.py | 8 +- src/handlers.py | 46 +++---- src/tables.txt | 9 -- 6 files changed, 209 insertions(+), 195 deletions(-) delete mode 100644 src/tables.txt diff --git a/html/index.html b/html/index.html index 2123bbe..05a8e96 100644 --- a/html/index.html +++ b/html/index.html @@ -6,8 +6,6 @@ JBond telegram web app - - @@ -15,12 +13,11 @@
-
- - + Срок, мес
@@ -29,12 +26,11 @@
-
- - + Доходность, %
@@ -43,12 +39,11 @@
-
- - + Купон, %
@@ -57,12 +52,11 @@
-
- - + Дюрация, мес
@@ -70,13 +64,15 @@
+
+ + +
+
+
-
-
- -
-
+
@@ -112,19 +108,19 @@
- +
- +
- +
- +
@@ -136,7 +132,9 @@

-
+ +
+
diff --git a/html/script.js b/html/script.js index fb1fa3f..de7a936 100644 --- a/html/script.js +++ b/html/script.js @@ -4,7 +4,6 @@ tg.expand(); // Expand the app to full screen tg.ready(); // Notify Telegram that the app is ready function send_filters() { - let filters = { is_qual: document.getElementById('is_qual').checked, is_amort: document.getElementById('is_amort').checked, @@ -29,6 +28,7 @@ function send_filters() { order: Array.from(document.getElementsByClassName("order")).filter((elem) => (elem.checked)).map((elem) => (elem.value)).toString(), key: Array.from(document.getElementsByClassName("sort-by")).filter((elem) => (elem.checked)).map((elem) => (elem.value)).toString(), }, + price: document.getElementById('price-to').value, rating: document.getElementById('rating-from').value, period: Array.from(document.getElementsByClassName("coupon-period-ckeckbox")).filter((elem) => (elem.checked)).map((elem) => parseInt(elem.value)), chat_id: tg.initDataUnsafe.user.id @@ -51,170 +51,198 @@ function send_filters() { }) .then(data => { console.log(JSON.stringify(data)) + tg.close(); }) .catch(error => { console.error('Error:', error); }); } -// tg.MainButton.setText('Готово'); -// tg.MainButton.show(); -// tg.MainButton.onClick(() => { -// send_filters(); -// // tg.sendData(json); -// tg.close(); -// }); - - let btn_ok = document.getElementById("btn_ok"); btn_ok.addEventListener('click', () => { send_filters(); - // tg.close(); -}); - - - -var redemptionSlider = document.getElementById('redemption-slider'); -noUiSlider.create(redemptionSlider, { - start: [3, 36], - connect: true, - range: { - 'min': 0, - 'max': 120 - } }); -var redemptionFrom = document.getElementById('redemption-from'); -var redemptionTo = document.getElementById('redemption-to'); -var redemptions = [redemptionFrom, redemptionTo]; +{ + Array.from(document.getElementsByClassName("sort-asc"), (elem, _) => elem.addEventListener('click', function() { + document.getElementById("order-asc").checked = true; + document.getElementById("order-dsc").checked = false; + })); -redemptionSlider.noUiSlider.on('update', function (values, handle) { - redemptions[handle].value = Math.round(values[handle]); -}); - -redemptionFrom.addEventListener('change', function () { - redemptionSlider.noUiSlider.set([null, this.value]); -}); -redemptionTo.addEventListener('change', function () { - redemptionSlider.noUiSlider.set([null, this.value]); -}); + Array.from(document.getElementsByClassName("sort-dsc"), (elem, _) => elem.addEventListener('click', function() { + document.getElementById("order-asc").checked = false; + document.getElementById("order-dsc").checked = true; + })); +} +{ + var redemptionSlider = document.getElementById('redemption-slider'); + noUiSlider.create(redemptionSlider, { + start: [3, 36], + connect: true, + range: { + 'min': 0, + 'max': 120 + } + }); + var redemptionFrom = document.getElementById('redemption-from'); + var redemptionTo = document.getElementById('redemption-to'); + var redemptions = [redemptionFrom, redemptionTo]; -// Слайдер доходности -var profitSlider = document.getElementById('profit-slider'); -noUiSlider.create(profitSlider, { - start: [20.0, 50.0], - connect: true, - range: { - 'min': 0.0, - 'max': 50.0 - } -}); + redemptionSlider.noUiSlider.on('update', function (values, handle) { + redemptions[handle].value = Math.round(values[handle]); + }); -var profitFrom = document.getElementById('profit-from'); -var profitTo = document.getElementById('profit-to'); -var profits = [profitFrom, profitTo]; + redemptionFrom.addEventListener('change', function () { + redemptionSlider.noUiSlider.set([null, this.value]); + }); + redemptionTo.addEventListener('change', function () { + redemptionSlider.noUiSlider.set([null, this.value]); + }); +} -profitSlider.noUiSlider.on('update', function (values, handle) { - profits[handle].value = Math.round(values[handle]*2)/2; -}); +{ + // Слайдер доходности + var profitSlider = document.getElementById('profit-slider'); + noUiSlider.create(profitSlider, { + start: [20.0, 50.0], + connect: true, + range: { + 'min': 0.0, + 'max': 50.0 + } + }); -profitFrom.addEventListener('change', function () { - profitSlider.noUiSlider.set([null, this.value]); -}); -profitTo.addEventListener('change', function () { - profitSlider.noUiSlider.set([null, this.value]); -}); + var profitFrom = document.getElementById('profit-from'); + var profitTo = document.getElementById('profit-to'); + var profits = [profitFrom, profitTo]; + profitSlider.noUiSlider.on('update', function (values, handle) { + profits[handle].value = Math.round(values[handle]*2)/2; + }); + profitFrom.addEventListener('change', function () { + profitSlider.noUiSlider.set([null, this.value]); + }); + profitTo.addEventListener('change', function () { + profitSlider.noUiSlider.set([null, this.value]); + }); +} -//Слайдер див доходности -var couponsSlider = document.getElementById('coupons-slider'); -noUiSlider.create(couponsSlider, { - start: [20.0, 50.0], - connect: true, - range: { - 'min': 0.0, - 'max': 50.0 - } -}); +{ + //Слайдер див доходности + var couponsSlider = document.getElementById('coupons-slider'); + noUiSlider.create(couponsSlider, { + start: [20.0, 50.0], + connect: true, + range: { + 'min': 0.0, + 'max': 50.0 + } + }); -var couponsFrom = document.getElementById('coupons-from'); -var couponsTo = document.getElementById('coupons-to'); -var coupons = [couponsFrom, couponsTo]; + var couponsFrom = document.getElementById('coupons-from'); + var couponsTo = document.getElementById('coupons-to'); + var coupons = [couponsFrom, couponsTo]; -couponsSlider.noUiSlider.on('update', function (values, handle) { - coupons[handle].value = Math.round(values[handle]*2)/2; -}); + couponsSlider.noUiSlider.on('update', function (values, handle) { + coupons[handle].value = Math.round(values[handle]*2)/2; + }); -couponsFrom.addEventListener('change', function () { - couponsSlider.noUiSlider.set([null, this.value]); -}); + couponsFrom.addEventListener('change', function () { + couponsSlider.noUiSlider.set([null, this.value]); + }); -couponsTo.addEventListener('change', function () { - couponsSlider.noUiSlider.set([null, this.value]); -}); + couponsTo.addEventListener('change', function () { + couponsSlider.noUiSlider.set([null, this.value]); + }); +} +{ + //Слайдер дюрации + var durationSlider = document.getElementById('duration-slider'); + noUiSlider.create(durationSlider, { + start: [0, 50], + connect: true, + range: { + 'min': 0, + 'max': 50 + } + }); -//Слайдер дюрации -var durationSlider = document.getElementById('duration-slider'); -noUiSlider.create(durationSlider, { - start: [0, 50], - connect: true, - range: { - 'min': 0, - 'max': 50 - } -}); + var durationFrom = document.getElementById('duration-from'); + var durationTo = document.getElementById('duration-to'); + var duration = [durationFrom, durationTo]; -var durationFrom = document.getElementById('duration-from'); -var durationTo = document.getElementById('duration-to'); -var duration = [durationFrom, durationTo]; + durationSlider.noUiSlider.on('update', function (values, handle) { + duration[handle].value = Math.round(values[handle]*2)/2; + }); -durationSlider.noUiSlider.on('update', function (values, handle) { - duration[handle].value = Math.round(values[handle]*2)/2; -}); + durationFrom.addEventListener('change', function () { + durationSlider.noUiSlider.set([null, this.value]); + }); -durationFrom.addEventListener('change', function () { - durationSlider.noUiSlider.set([null, this.value]); -}); + durationTo.addEventListener('change', function () { + durationSlider.noUiSlider.set([null, this.value]); + }); +} -durationTo.addEventListener('change', function () { - durationSlider.noUiSlider.set([null, this.value]); -}); +{ + //Слайдер цены + var priceSlider = document.getElementById('price-slider'); + var price = document.getElementById('price-to'); + + noUiSlider.create(priceSlider, { + start: [100], + connect: 'lower', + step: 1, + range: { + 'min': 0, + 'max': 200 + } + }); + priceSlider.noUiSlider.on('update', function (value) { + price.value = Math.round(value*2)/2;; + }); + price.addEventListener('change', function () { + priceSlider.noUiSlider.set([this.value]); + }); +} -//Слайдер рейтинга -var ratings = ['BB-','BB','BB+','BBB-','BBB','BBB+','A-','A','A+','AA-','AA','AA+','AAA-','AAA']; -var ratingFormat = { - to: function(value) { - return ratings[value]; - }, - from: function (value) { - return ratings.indexOf(value); - } -}; - -var ratingSlider = document.getElementById('rating-slider'); -var ratingFrom = document.getElementById('rating-from'); - -noUiSlider.create(ratingSlider, { - start: ['A-'], - connect: 'upper', - format: ratingFormat, - step: 1, - range: { - 'min': 0, - 'max': ratings.length - 1 +{ + //Слайдер рейтинга + var ratings = ['BB-','BB','BB+','BBB-','BBB','BBB+','A-','A','A+','AA-','AA','AA+','AAA-','AAA']; + var ratingFormat = { + to: function(value) { + return ratings[value]; + }, + from: function (value) { + return ratings.indexOf(value); } -}); + }; -ratingSlider.noUiSlider.on('update', function (value) { - ratingFrom.value = value; -}); + var ratingSlider = document.getElementById('rating-slider'); + var ratingFrom = document.getElementById('rating-from'); + + noUiSlider.create(ratingSlider, { + start: ['A-'], + connect: 'upper', + format: ratingFormat, + step: 1, + range: { + 'min': 0, + 'max': ratings.length - 1 + } + }); -ratingFrom.addEventListener('change', function () { - ratingSlider.noUiSlider.set([this.value]); -}); + ratingSlider.noUiSlider.on('update', function (value) { + ratingFrom.value = value; + }); + + ratingFrom.addEventListener('change', function () { + ratingSlider.noUiSlider.set([this.value]); + }); +} diff --git a/html/style.css b/html/style.css index 8a873ac..ddc4b75 100644 --- a/html/style.css +++ b/html/style.css @@ -62,7 +62,6 @@ button:active { .separator { display: inline-block; - margin: 10px; } .field-left { @@ -76,10 +75,10 @@ button:active { } input[type='number'] { - width: 80px; + width: 44px; padding: 7px; text-align: center; - border: 1px solid #c8c8c8; + border: 0px solid #c8c8c8; border-radius: 5px; font-size: 18px; line-height: 18px; @@ -87,10 +86,10 @@ input[type='number'] { } input[type='text'] { - width: 80px; + width: 44px; padding: 7px; text-align: center; - border: 1px solid #c8c8c8; + border: 0px solid #c8c8c8; border-radius: 5px; font-size: 18px; line-height: 18px; @@ -117,10 +116,6 @@ input[type='radio'] { margin: 10px 0px 0px; } -.single-slider { - margin-top: 50px -} - .slider .noUi-connect { background: #c0392b; } diff --git a/src/bonds/getter.py b/src/bonds/getter.py index 2498227..7dd0ea0 100644 --- a/src/bonds/getter.py +++ b/src/bonds/getter.py @@ -14,13 +14,15 @@ def columns(self): return self.__columns__ def __needed__(self, filters, paper): - offer = paper['OFFERDATE'] - if offer and not filters.is_offer: + if paper['OFFERDATE'] and not filters.is_offer: return False if paper[filters.sort.key] == 0: return False - + + if float(paper["PRICE"]) > float(filters.price): + return False + return True def __sort__(self, key, order, papers): diff --git a/src/handlers.py b/src/handlers.py index a6af8a5..a0ccf50 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -12,25 +12,25 @@ logger = logging.getLogger("Router") -@base_router.message(CommandStart()) -async def handle_start(message: Message): - logger.info(f'Command /start from {message.from_user.full_name}') - address = config["server"]["address"] - port = config["server"]["port"] - webAppInfo = WebAppInfo(url = f"https://{address}:{port}") - builder = ReplyKeyboardBuilder() - builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) - await message.answer(text = f'Поехали', reply_markup = builder.as_markup()) - -@base_router.message(Command('filters')) -async def cmd_filters(message: Message): - logger.info(f'Command /filters from {message.from_user.full_name}') - address = config["server"]["address"] - port = config["server"]["port"] - webAppInfo = WebAppInfo(url = f"https://{address}:{port}") - builder = ReplyKeyboardBuilder() - builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) - await message.answer(text = 'Хуильтры', reply_markup = builder.as_markup()) +# @base_router.message(CommandStart()) +# async def handle_start(message: Message): +# logger.info(f'Command /start from {message.from_user.full_name}') +# address = config["server"]["address"] +# port = config["server"]["port"] +# webAppInfo = WebAppInfo(url = f"https://{address}:{port}") +# builder = ReplyKeyboardBuilder() +# builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) +# await message.answer(text = f'Поехали', reply_markup = builder.as_markup()) + +# @base_router.message(Command('filters')) +# async def cmd_filters(message: Message): +# logger.info(f'Command /filters from {message.from_user.full_name}') +# address = config["server"]["address"] +# port = config["server"]["port"] +# webAppInfo = WebAppInfo(url = f"https://{address}:{port}") +# builder = ReplyKeyboardBuilder() +# builder.add(KeyboardButton(text = 'Открыть фильтры', web_app = webAppInfo)) +# await message.answer(text = 'Хуильтры', reply_markup = builder.as_markup()) @base_router.message(Command('clear')) async def cmd_clear(message: Message): @@ -46,10 +46,10 @@ async def cmd_clear(message: Message): logger.info(f'History has been cleared') -@base_router.message(lambda message: message.web_app_data) -async def handle_web_app_data(message: Message): - logger.info(f'{message.web_app_data}') - await message.answer(f'{message.web_app_data}') +# @base_router.message(lambda message: message.web_app_data) +# async def handle_web_app_data(message: Message): +# logger.info(f'{message.web_app_data}') +# await message.answer(f'{message.web_app_data}') @base_router.callback_query(F.data == "more") diff --git a/src/tables.txt b/src/tables.txt deleted file mode 100644 index e14252f..0000000 --- a/src/tables.txt +++ /dev/null @@ -1,9 +0,0 @@ - -DEFOLTS = 'https://web.moex.com/moex-web-icdb-api/api/v1/export/site-defaults/xlsx' - - -Погашение и амортизация: -https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=amortizations,amortizations.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 - -Оферты -https://iss.moex.com/iss/statistics/engines/stock/markets/bonds/bondization.json?from=2025-06-19&till=2025-07-05&start=0&limit=100&iss.only=offers,offers.cursor&sort_order=desc&iss.json=extended&iss.meta=off&lang=ru&is_traded=1 From 795aa7be163dabf26d1fc59718a8e91069905156 Mon Sep 17 00:00:00 2001 From: andrevis Date: Sun, 6 Jul 2025 21:46:36 +0300 Subject: [PATCH 06/10] defaults --- html/script.js | 1 + 1 file changed, 1 insertion(+) diff --git a/html/script.js b/html/script.js index de7a936..4440859 100644 --- a/html/script.js +++ b/html/script.js @@ -165,6 +165,7 @@ btn_ok.addEventListener('click', () => { noUiSlider.create(durationSlider, { start: [0, 50], connect: true, + step: 1, range: { 'min': 0, 'max': 50 From 7b1f627a14b76a751f9a8c6e002e144623d98234 Mon Sep 17 00:00:00 2001 From: andrevis Date: Sun, 6 Jul 2025 21:53:18 +0300 Subject: [PATCH 07/10] defaults --- src/messages.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/messages.py b/src/messages.py index 5ac47df..4f0d6c2 100644 --- a/src/messages.py +++ b/src/messages.py @@ -41,7 +41,7 @@ def get_defaults(self, isin): else: return ', '.join(defaults) - async def __call__(self): + async def __call__(self, last: bool): logger.info(f'Send message to {self.chat_id}') try: isin = self.paper["ISIN"] @@ -70,7 +70,12 @@ async def __call__(self): ❌ Дефолты:\t{defaults} ⏰ Дюрация:\t{round(duration/30, 1)} мес ({duration} дней)''' - await bot.send_message(chat_id=self.chat_id, text=text, disable_notification=True, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True)) + if last: + builder = InlineKeyboardBuilder() + builder.add(InlineKeyboardButton(text="Показать еще", callback_data="more")) + await bot.send_message(chat_id=self.chat_id, text=text, disable_notification=True, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True), reply_markup=builder.as_markup()) + else: + await bot.send_message(chat_id=self.chat_id, text=text, disable_notification=True, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True)) except Exception as e: logger.error(f'Cannot send bot message len={len(text)}: {e}\n{text}') @@ -104,7 +109,7 @@ def append(self, message: SendMessageTask): def __len__(self): return len(self.messages) - async def __pending__(self): + def __pending__(self): while not pending_messages.empty(): pending_messages.get() @@ -113,10 +118,6 @@ async def __pending__(self): pending.offset = self.offset + self.shift pending_messages.put(pending) - builder = InlineKeyboardBuilder() - builder.add(InlineKeyboardButton(text="Показать еще", callback_data="more")) - await bot.send_message(chat_id=self.chat_id, text=f'❗ Показано только {self.shift} из {len(self.messages)} результатов', disable_notification=True, reply_markup=builder.as_markup()) - async def __call__(self): formatted_datetime = datetime.now().strftime("%d-%m-%Y %H:%M:%S") if self.offset == 0: @@ -124,6 +125,6 @@ async def __call__(self): for i, job in enumerate(self.messages): if i == self.shift: - await self.__pending__() + self.__pending__() return - await job() + await job(i == self.shift - 1) From 85f0a27f7dec29b34dd154289259c7716a7d97b6 Mon Sep 17 00:00:00 2001 From: andrevis Date: Sat, 12 Jul 2025 16:38:14 +0300 Subject: [PATCH 08/10] fixup --- .github/workflows/ci.yml | 2 +- html/index.html | 21 ++++++++++---- html/script.js | 1 + src/bonds/defaults.py | 2 +- src/bonds/getter.py | 7 +++-- src/bonds/request.py | 13 +++++---- src/http_server.py | 2 +- src/messages.py | 63 +++++++++++++++++++++++++++++----------- 8 files changed, 78 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ca18e4..5e782e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: python3 -m ensurepip python3 -m venv /opt/certbot/ python3 -m pip install --upgrade pip - pip3 install aiogram pandas apscheduler tomli certbot py-postgresql + pip3 install aiogram pandas apscheduler tomli certbot py-postgresql beautifulsoup4 lxml # - name: Install some html stuff # run: | diff --git a/html/index.html b/html/index.html index 05a8e96..c9e9d3b 100644 --- a/html/index.html +++ b/html/index.html @@ -80,10 +80,10 @@
- - - - + + + +
@@ -122,10 +122,21 @@
- +
+
+
+ +
+ + + +
+
+
+

diff --git a/html/script.js b/html/script.js index 4440859..4066cac 100644 --- a/html/script.js +++ b/html/script.js @@ -31,6 +31,7 @@ function send_filters() { price: document.getElementById('price-to').value, rating: document.getElementById('rating-from').value, period: Array.from(document.getElementsByClassName("coupon-period-ckeckbox")).filter((elem) => (elem.checked)).map((elem) => parseInt(elem.value)), + listing: Array.from(document.getElementsByClassName("listing-ckeckbox")).filter((elem) => (elem.checked)).map((elem) => parseInt(elem.value)), chat_id: tg.initDataUnsafe.user.id }; diff --git a/src/bonds/defaults.py b/src/bonds/defaults.py index 2999b24..ece700e 100644 --- a/src/bonds/defaults.py +++ b/src/bonds/defaults.py @@ -30,5 +30,5 @@ def get(isin): records = excel_data.to_dict(orient='records') records = list(filter(lambda record : record['ISIN'] == isin, records)) - records = list(map(lambda record : record['Плановая дата'], records)) + records = list(map(lambda record : str(record['Плановая дата']), records)) return records diff --git a/src/bonds/getter.py b/src/bonds/getter.py index 7dd0ea0..a9816dc 100644 --- a/src/bonds/getter.py +++ b/src/bonds/getter.py @@ -22,7 +22,10 @@ def __needed__(self, filters, paper): if float(paper["PRICE"]) > float(filters.price): return False - + + if int(paper['SUSPENSION_LISTING']) == 1: + return False + return True def __sort__(self, key, order, papers): @@ -71,7 +74,7 @@ def get(self, filters): logger.info(f'Scrolling from {offset} to {total} by {scroll}') # https://iss.moex.com/iss/apps/infogrid/emission/rates.json?lang=ru&iss.meta=off&sort_order=dsc&sort_column=YIELDATWAP&start=0&limit=1000&coupon_frequency=4,12&redemption=60,1080&coupon_percent=20,50&columns=SECID,SHORTNAME,ISIN,FACEVALUE,FACEUNIT,MATDATE,COUPONFREQUENCY,COUPONPERCENT,OFFERDATE,DAYSTOREDEMPTION,SECSUBTYPE,YIELDATWAP,COUPONDATE,PRICE_RUB,PRICE,REPLBOND,ISSUEDATE,COUPONLENGTH,TYPENAME,DURATION,IS_QUALIFIED_INVESTORS&sec_type=stock_corporate_bond,stock_exchange_bond¤cyid=rub&high_risk=0 - url = str(BondsRequest().lang().meta(False).sort(filters.sort.order, filters.sort.key).scroll(offset, scroll).period(filters.period).redemption(filters.redemption.fr, filters.redemption.to).coupons(filters.coupons.fr, filters.coupons.to).qual(filters.is_qual).amortization(filters.is_amort).columns().sec_type().currencyid().high_risk(False).listing([1,2])) + url = str(BondsRequest().lang().meta(False).sort(filters.sort.order, filters.sort.key).scroll(offset, scroll).period(filters.period).redemption(filters.redemption.fr, filters.redemption.to).coupons(filters.coupons.fr, filters.coupons.to).qual(filters.is_qual).amortization(filters.is_amort).columns().sec_type().currencyid().high_risk(False).listing(filters.listing)) logger.info(f'Request: {url}') response = requests.get(url, timeout=5) diff --git a/src/bonds/request.py b/src/bonds/request.py index 9d7e121..9a2fb27 100644 --- a/src/bonds/request.py +++ b/src/bonds/request.py @@ -33,15 +33,16 @@ class BondsRequest: 'SECSUBTYPE', 'YIELDATWAP', 'COUPONDATE', - 'PRICE_RUB', - 'PRICE', + # 'PRICE_RUB', + # 'PRICE', 'REPLBOND', 'ISSUEDATE', 'COUPONLENGTH', 'TYPENAME', 'DURATION', 'IS_QUALIFIED_INVESTORS', - 'LISTLEVEL' + 'LISTLEVEL', + 'WAPRICE' ] @property @@ -83,12 +84,12 @@ def columns(self, optional = None): def amortization(self, include=True): if not include: self.__req__ += f'amortization=0&' - return self + return self def qual(self, include=True): if not include: self.__req__ += f'qi=0&' - return self + return self def coupons(self, fr, to): self.__req__ += f'coupon_percent={fr},{to}&' @@ -111,7 +112,7 @@ def high_risk(self, value=False): self.__req__ += f'high_risk={0}&' return self - def listing(self, listname=[1,2,3]): + def listing(self, listname=[1,2,3]): val = ','.join(map(str, listname)) self.__req__ += f'listname={val}&' return self diff --git a/src/http_server.py b/src/http_server.py index 6736ad3..247ca1c 100644 --- a/src/http_server.py +++ b/src/http_server.py @@ -54,7 +54,7 @@ def do_POST(self): self.__send_response__(200, 'application/json', bytes(json.dumps(json_bonds, indent=0), 'utf-8')) - message_pack = MessagePack(filters.chat_id, filters.sort.key) + message_pack = MessagePack(filters) for paper in json_bonds: message_pack.append(SendMessageTask(filters.chat_id, paper)) diff --git a/src/messages.py b/src/messages.py index 4f0d6c2..ae3f4ea 100644 --- a/src/messages.py +++ b/src/messages.py @@ -7,9 +7,12 @@ from bot import bot from logger import * from bonds.defaults import DefaultsGetter +from bonds.rating import RatingGetter logger = logging.getLogger("Messages") +ratings = ['BB-','BB','BB+','BBB-','BBB','BBB+','A-','A','A+','AA-','AA','AA+','AAA-','AAA'] + messages_queue = queue.Queue() pending_messages = queue.Queue() @@ -45,23 +48,27 @@ async def __call__(self, last: bool): logger.info(f'Send message to {self.chat_id}') try: isin = self.paper["ISIN"] - name = self.paper["SHORTNAME"] + name = self.paper["NAME"] + shortname = self.paper["SHORTNAME"] nominal = self.get_float("FACEVALUE") redemption = self.get_int("DAYSTOREDEMPTION") duration = self.get_int("DURATION") coupon = self.get_float("COUPONPERCENT") yieldatwap = self.get_float("YIELDATWAP") couponlength = self.get_int("COUPONLENGTH") - price_percent = self.get_float("PRICE") - price_rub = self.get_float("PRICE_RUB") + price = self.get_float("WAPRICE") qual = "да" if self.get_int("IS_QUALIFIED_INVESTORS") == 0 else "нет" offer = self.paper['OFFERDATE'] if self.paper['OFFERDATE'] else "нет" defaults = self.get_defaults(isin) matdate = self.paper["MATDATE"] - - text = f'''📌 Имя:\t{name} -🔎 ISIN:\t{isin} -💲 Цена:\t{price_percent}% ({price_rub}₽ / {nominal}₽) + listlevel = self.paper['LISTLEVEL'] + price_rub = nominal * price / 100.0 + rating = self.paper['RATING'] + + text = f'''📌 {name} +🔎 ISIN:\t{isin}|Moex|T-Broker +🆎 Рейтинг:\t{rating}, Листинг: {listlevel} +💲 Цена:\t{price:.2f}% ({price_rub:.1f}₽ / {nominal}₽) 📈 Доходность:\t{yieldatwap}% 📆 Купон:\t{coupon}% (раз в {round(couponlength/30)} мес.) ⏳ Погашение:\t{matdate} ({redemption} д.) @@ -84,10 +91,9 @@ async def __call__(self, last: bool): class MessagePack: shift = 5 - def __init__(self, chat_id, sortby): + def __init__(self, filters): + self.filters = filters self.messages = [] - self.chat_id = chat_id - self.sortby = sortby self.offset = 0 self.idx = 0 @@ -113,7 +119,7 @@ def __pending__(self): while not pending_messages.empty(): pending_messages.get() - pending = MessagePack(self.chat_id, self.sortby) + pending = MessagePack(self.filters) pending.messages = self.messages[self.shift:] pending.offset = self.offset + self.shift pending_messages.put(pending) @@ -121,10 +127,33 @@ def __pending__(self): async def __call__(self): formatted_datetime = datetime.now().strftime("%d-%m-%Y %H:%M:%S") if self.offset == 0: - await bot.send_message(chat_id=self.chat_id, text=f'=== {formatted_datetime} ({self.shift} из {len(self.messages)})') + await bot.send_message(chat_id= self.filters.chat_id, text=f'=== {formatted_datetime} ({self.shift} из {len(self.messages)})') + + done = 0 + total = 0 + while done < self.shift and total < len(self.messages): + messages = self.messages[total:] + + batch = min(self.shift, len(messages)) + rating_tasks = [asyncio.to_thread(RatingGetter.get, job.paper['ISIN']) for job in messages[:batch]] + results = await asyncio.gather(*rating_tasks) + + for i, result in enumerate(results): + total += 1 + if result == None: + continue + + job = messages[i] + job.paper['RATING'] = results[i] + + if ratings.index(job.paper['RATING']) < ratings.index(self.filters.rating): + continue + + done += 1 + is_last = (done == self.shift) or (total == len(self.messages)) + await job(is_last) + + + self.__pending__() + return - for i, job in enumerate(self.messages): - if i == self.shift: - self.__pending__() - return - await job(i == self.shift - 1) From 6a3a6bdef67e9b517b60e986518b68f5191bca80 Mon Sep 17 00:00:00 2001 From: andrevis Date: Sat, 12 Jul 2025 16:40:06 +0300 Subject: [PATCH 09/10] fixup --- src/bonds/rating.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/bonds/rating.py diff --git a/src/bonds/rating.py b/src/bonds/rating.py new file mode 100644 index 0000000..194bacf --- /dev/null +++ b/src/bonds/rating.py @@ -0,0 +1,29 @@ +import requests +from bs4 import BeautifulSoup +from logger import * +from lxml import etree + +logger = logging.getLogger("Rating") + + +class RatingGetter: + + @staticmethod + def get(isin): + try: + url = f'https://analytics.dohod.ru/bond/{isin}' + + response = requests.get(url, timeout=5) + if response.status_code != 200: + logger.error(f'Cannot get rating (code={response.status_code}): {response.reason}') + return None + + root = BeautifulSoup(response.content, 'html.parser') + dom = etree.HTML(str(root)) + return dom.xpath('//*[@id="liquidityScroll"]/div[3]/div[2]/div/div[2]/p/span/span[1]/span')[0].text + except ValueError as e: + logger.error(f'Cannot get rating: {e}') + return None + + + From a431213344f6b2c886350052bda3d8807695b795 Mon Sep 17 00:00:00 2001 From: andrevis Date: Sat, 12 Jul 2025 16:54:49 +0300 Subject: [PATCH 10/10] fixup --- src/messages.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/messages.py b/src/messages.py index ae3f4ea..8b3b7db 100644 --- a/src/messages.py +++ b/src/messages.py @@ -139,7 +139,8 @@ async def __call__(self): results = await asyncio.gather(*rating_tasks) for i, result in enumerate(results): - total += 1 + total += 1 + if result == None: continue @@ -153,7 +154,7 @@ async def __call__(self): is_last = (done == self.shift) or (total == len(self.messages)) await job(is_last) - - self.__pending__() - return + if is_last: + self.__pending__() + return