diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 479c957..5e782e4 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 pandas apscheduler tomli certbot py-postgresql beautifulsoup4 lxml # - name: Install some html stuff # run: | 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 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..c9e9d3b 100644 --- a/html/index.html +++ b/html/index.html @@ -6,76 +6,136 @@ JBond telegram web app - - -
-
- +
- - + Срок, мес
- +
-
+
-
- +
- - + Доходность, %
- +
-
+
-
- +
- - + Купон, %
- +
-
+
-
- + +
+ Дюрация, мес +
+
+
+
+ +
+ + +
+
+ +
+ +
-
- +
+ +
+ + + + + +
+
+
+ +
-
-
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+ + + +
+
+

@@ -83,12 +143,12 @@

-
- + +
+ +
- - \ No newline at end of file diff --git a/html/script.js b/html/script.js index 0758bdc..4066cac 100644 --- a/html/script.js +++ b/html/script.js @@ -1,42 +1,38 @@ - - - -// 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, + is_offer: document.getElementById('is_offer').checked, + redemption: { + fr: parseInt(document.getElementById('redemption-from').value) * 30, + to: parseInt(document.getElementById('redemption-to').value) * 30, + }, + 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), + }, 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) * 30, + to: parseInt(document.getElementById('duration-to').value) * 30, }, - - // toString() { - // return `{"is_qual": ${this.is_qual}, "is_amort": ${this.is_amort}}`; - // } + 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(), + }, + 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 }; document.getElementById('filters').value = JSON.stringify(filters); @@ -45,143 +41,210 @@ 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 => { + console.log(JSON.stringify(data)) + tg.close(); + }) + .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(); -// // tg.sendData(json); -// tg.close(); -// }); - - let btn_ok = document.getElementById("btn_ok"); btn_ok.addEventListener('click', () => { - send_data(); - //tg.close(); + send_filters(); }); +{ + Array.from(document.getElementsByClassName("sort-asc"), (elem, _) => elem.addEventListener('click', function() { + document.getElementById("order-asc").checked = true; + document.getElementById("order-dsc").checked = false; + })); + Array.from(document.getElementsByClassName("sort-dsc"), (elem, _) => elem.addEventListener('click', function() { + document.getElementById("order-asc").checked = false; + document.getElementById("order-dsc").checked = true; + })); +} -//Слайдер дюрации -var durationSlider = document.getElementById('duration-slider'); -noUiSlider.create(durationSlider, { - start: [3, 36], - connect: true, - range: { - 'min': 0, - 'max': 120 - } -}); +{ + var redemptionSlider = document.getElementById('redemption-slider'); + noUiSlider.create(redemptionSlider, { + start: [3, 36], + connect: true, + range: { + 'min': 0, + 'max': 120 + } + }); -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]); -}); -durationTo.addEventListener('change', function () { - durationSlider.noUiSlider.set([null, this.value]); -}); + redemptionFrom.addEventListener('change', function () { + redemptionSlider.noUiSlider.set([null, this.value]); + }); + redemptionTo.addEventListener('change', function () { + redemptionSlider.noUiSlider.set([null, this.value]); + }); +} +{ + // Слайдер доходности + var profitSlider = document.getElementById('profit-slider'); + noUiSlider.create(profitSlider, { + start: [20.0, 50.0], + connect: true, + range: { + 'min': 0.0, + 'max': 50.0 + } + }); + var profitFrom = document.getElementById('profit-from'); + var profitTo = document.getElementById('profit-to'); + var profits = [profitFrom, profitTo]; -// Слайдер полной доходности -var fullProfitSlider = document.getElementById('full-profit-slider'); -noUiSlider.create(fullProfitSlider, { - start: [20.0, 50.0], - connect: true, - range: { - 'min': 0.0, - 'max': 50.0 - } -}); + profitSlider.noUiSlider.on('update', function (values, handle) { + profits[handle].value = Math.round(values[handle]*2)/2; + }); -var duratfullProfitFrom = document.getElementById('full-profit-from'); -var duratfullProfitTo = document.getElementById('full-profit-to'); -var fullProfits = [duratfullProfitFrom, duratfullProfitTo]; + profitFrom.addEventListener('change', function () { + profitSlider.noUiSlider.set([null, this.value]); + }); + profitTo.addEventListener('change', function () { + profitSlider.noUiSlider.set([null, this.value]); + }); +} -fullProfitSlider.noUiSlider.on('update', function (values, handle) { - fullProfits[handle].value = Math.round(values[handle]*2)/2; -}); +{ + //Слайдер див доходности + var couponsSlider = document.getElementById('coupons-slider'); + noUiSlider.create(couponsSlider, { + start: [20.0, 50.0], + connect: true, + range: { + 'min': 0.0, + 'max': 50.0 + } + }); -duratfullProfitFrom.addEventListener('change', function () { - fullProfitSlider.noUiSlider.set([null, this.value]); -}); -duratfullProfitTo.addEventListener('change', function () { - fullProfitSlider.noUiSlider.set([null, this.value]); -}); + 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; + }); + couponsFrom.addEventListener('change', function () { + couponsSlider.noUiSlider.set([null, this.value]); + }); -//Слайдер див доходности -var divProfitSlider = document.getElementById('div-profit-slider'); -noUiSlider.create(divProfitSlider, { - start: [20.0, 50.0], - connect: true, - range: { - 'min': 0.0, - 'max': 50.0 - } -}); + couponsTo.addEventListener('change', function () { + couponsSlider.noUiSlider.set([null, this.value]); + }); +} -var divProfitFrom = document.getElementById('div-profit-from'); -var divProfitTo = document.getElementById('div-profit-to'); -var divProfits = [divProfitFrom, divProfitTo]; +{ + //Слайдер дюрации + var durationSlider = document.getElementById('duration-slider'); + noUiSlider.create(durationSlider, { + start: [0, 50], + connect: true, + step: 1, + range: { + 'min': 0, + 'max': 50 + } + }); -divProfitSlider.noUiSlider.on('update', function (values, handle) { - divProfits[handle].value = Math.round(values[handle]*2)/2; -}); + var durationFrom = document.getElementById('duration-from'); + var durationTo = document.getElementById('duration-to'); + var duration = [durationFrom, durationTo]; -divProfitFrom.addEventListener('change', function () { - divProfitSlider.noUiSlider.set([null, this.value]); -}); + durationSlider.noUiSlider.on('update', function (values, handle) { + duration[handle].value = Math.round(values[handle]*2)/2; + }); -divProfitTo.addEventListener('change', function () { - divProfitSlider.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]); + }); +} + +{ + //Слайдер цены + 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;; + }); -//Слайдер рейтинга -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 + 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); } -}); + }; -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 232ebb6..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; @@ -98,20 +97,25 @@ 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; } -.single-slider { - margin-top: 50px -} - .slider .noUi-connect { background: #c0392b; } diff --git a/src/bonds/defaults.py b/src/bonds/defaults.py new file mode 100644 index 0000000..ece700e --- /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 : str(record['Плановая дата']), records)) + return records diff --git a/src/bonds/getter.py b/src/bonds/getter.py new file mode 100644 index 0000000..a9816dc --- /dev/null +++ b/src/bonds/getter.py @@ -0,0 +1,92 @@ +import requests +from logger import * +from bonds.request import BondsRequest +import json +from operator import attrgetter + +logger = logging.getLogger("Bond") + +class BondsGetter: + __columns__ = [] + + @property + def columns(self): + return self.__columns__ + + def __needed__(self, filters, paper): + 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 + + if int(paper['SUSPENSION_LISTING']) == 1: + return False + + return True + + 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, 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__(filters, 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}') + + total = 1000 + offset = 0 + + filtered_papers = [] + while offset < total: + scroll = min(100, total - offset) + 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(filters.listing)) + 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 += min(scroll, total-offset) + + self.__columns__ = json["rates"]["columns"] + filtered_papers.extend(self.__filter__(filters, json["rates"]["data"])) + + return self.__sort__(filters.sort.key, filters.sort.order, filtered_papers) diff --git a/src/bonds/request.py b/src/bonds/request.py new file mode 100644 index 0000000..9a2fb27 --- /dev/null +++ b/src/bonds/request.py @@ -0,0 +1,121 @@ +# https://iss.moex.com/iss/apps/infogrid/emission/rates.json? +# lang=ru& +# 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', + 'FACEUNIT', + 'MATDATE', + 'COUPONFREQUENCY', + 'COUPONPERCENT', + 'OFFERDATE', + 'DAYSTOREDEMPTION', + 'SECSUBTYPE', + 'YIELDATWAP', + 'COUPONDATE', + # 'PRICE_RUB', + # 'PRICE', + 'REPLBOND', + 'ISSUEDATE', + 'COUPONLENGTH', + 'TYPENAME', + 'DURATION', + 'IS_QUALIFIED_INVESTORS', + 'LISTLEVEL', + 'WAPRICE' + ] + + @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 = 'asc', 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 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 redemption(self, fr, to): + self.__req__ += f'redemption={fr},{to}&' + return self + + def sec_type(self): + 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]): + 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 new file mode 100644 index 0000000..7904794 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,5 @@ + +from config import config +from aiogram import Bot + +bot = Bot(token = config['bot']['token']) \ 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.py b/src/handlers.py new file mode 100644 index 0000000..a0ccf50 --- /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('Еще 5 облигаций -->') diff --git a/src/handlers/start.py b/src/handlers/start.py deleted file mode 100644 index 08fbf68..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 = 'Поехали', 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()) \ No newline at end of file diff --git a/src/handlers/web_app.py b/src/handlers/web_app.py deleted file mode 100644 index 9170c2c..0000000 --- a/src/handlers/web_app.py +++ /dev/null @@ -1,13 +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('Получи') diff --git a/src/http_server.py b/src/http_server.py index 9f7cf30..247ca1c 100644 --- a/src/http_server.py +++ b/src/http_server.py @@ -1,27 +1,73 @@ -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 * +from messages 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', int(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}') + try: + 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']) - self.send_response(200) - else: - self.send_response(400) + 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(filters) + for paper in json_bonds: + message_pack.append(SendMessageTask(filters.chat_id, paper)) + + if len(message_pack) > 0: + messages_queue.put(message_pack) + + else: + self.__send_response__(400) + return + except Exception as e: + logger.error(f'POST {self.path} exception: {e}') + self.__send_response__(500) - 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 +111,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 +120,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..5f7e91b 100644 --- a/src/main.py +++ b/src/main.py @@ -3,30 +3,35 @@ 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 +from apscheduler.schedulers.asyncio import AsyncIOScheduler logger = logging.getLogger("Main") - 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_message_pack, 'interval', seconds=3) + scheduler.start() - bot = Bot(token = config['bot']['token']) + dispatcher = Dispatcher(storage = MemoryStorage()) + dispatcher.include_router(base_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/messages.py b/src/messages.py new file mode 100644 index 0000000..ae3f4ea --- /dev/null +++ b/src/messages.py @@ -0,0 +1,159 @@ +import queue +from datetime import datetime +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 +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() + +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) + + 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, last: bool): + logger.info(f'Send message to {self.chat_id}') + try: + isin = self.paper["ISIN"] + 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 = 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"] + 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} д.) +🐹 Доступно для неквалов:\t{qual} +📞 Оферта:\t{offer} +❌ Дефолты:\t{defaults} +⏰ Дюрация:\t{round(duration/30, 1)} мес ({duration} дней)''' + + 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}') + + + +class MessagePack: + shift = 5 + + def __init__(self, filters): + self.filters = filters + self.messages = [] + 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) + + def __len__(self): + return len(self.messages) + + def __pending__(self): + while not pending_messages.empty(): + pending_messages.get() + + pending = MessagePack(self.filters) + pending.messages = self.messages[self.shift:] + pending.offset = self.offset + self.shift + pending_messages.put(pending) + + 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.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 + 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