Last active
June 26, 2025 19:57
-
-
Save YuriyGuts/0d997af3966f72951194ee5576ac9166 to your computer and use it in GitHub Desktop.
Extracts information about Ukrainian treasury bonds from the NBU API / Minfin website and presents it as CSV/TSV.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Extracts information about Ukrainian treasury bonds from the NBU API / Minfin | |
and presents it as CSV/TSV. | |
usage: bondinfo.py [-h] [--format {tsv,csv}] isin [isin ...] | |
positional arguments: | |
isin The ISIN to process. Multiple space-separated values are also accepted. | |
options: | |
--format {tsv,csv} Specify the output format. The default is 'tsv'. | |
Example: uv run bondinfo.py UA4000214506 UA4000215495 | |
""" | |
# /// script | |
# requires-python = ">=3.10" | |
# dependencies = [ | |
# "bs4", | |
# "requests", | |
# ] | |
# /// | |
import argparse | |
from datetime import datetime | |
import requests | |
from bs4 import BeautifulSoup | |
NBU_API_URL = "https://bank.gov.ua/depo_securities?json" | |
MINFIN_ISIN_URL_TEMPLATE = "https://index.minfin.com.ua/ua/finance/bonds/{isin}/" | |
USER_AGENT = "Mozilla/5.0" | |
MINFIN_COUPON_AMOUNT_LABEL = "Купон" | |
MINFIN_PAYMENT_SCHEDULE_LABEL = "Сплата відсотків" | |
MINFIN_MATURITY_DATE_LABEL = "Погашення" | |
MINFIN_SOURCE_DATE_FORMAT = "%d.%m.%Y" | |
MINFIN_OUTPUT_DATE_FORMAT = "%Y-%m-%d" | |
def download_nbu_bond_catalog(url: str) -> dict: | |
headers = {"User-Agent": USER_AGENT} | |
response = requests.get(url, headers=headers) | |
return response.json() | |
def download_minfin_isin_webpage(isin: str) -> str: | |
url = MINFIN_ISIN_URL_TEMPLATE.format(isin=isin.lower()) | |
headers = {"User-Agent": USER_AGENT} | |
response = requests.get(url, headers=headers) | |
response.encoding = "utf-8" | |
return response.text | |
def parse_minfin_payout_amounts(html: str) -> dict: | |
soup = BeautifulSoup(html, "html.parser") | |
# Find all <dt>/<dd> pairs. | |
dl = soup.find("dl") | |
dt_dd_pairs = list(zip(dl.find_all("dt"), dl.find_all("dd"))) | |
coupon_amount = None | |
coupon_dates = [] | |
maturity_date = None | |
# Parse the relevant fields. | |
for dt, dd in dt_dd_pairs: | |
label = dt.get_text(strip=True) | |
value = dd.get_text(separator=" ", strip=True) | |
if label.startswith(MINFIN_COUPON_AMOUNT_LABEL): | |
coupon_amount = value.replace(",", ".") | |
elif label.startswith(MINFIN_PAYMENT_SCHEDULE_LABEL): | |
coupon_dates = [ | |
datetime.strptime(date.strip(), MINFIN_SOURCE_DATE_FORMAT).strftime( | |
MINFIN_OUTPUT_DATE_FORMAT | |
) | |
for date in dd.stripped_strings | |
] | |
elif label.startswith(MINFIN_MATURITY_DATE_LABEL): | |
parsed_date = datetime.strptime(dd.get_text(strip=True), MINFIN_SOURCE_DATE_FORMAT) | |
maturity_date = parsed_date.strftime(MINFIN_OUTPUT_DATE_FORMAT) | |
# Assemble the results. | |
payouts = [] | |
for date in coupon_dates: | |
amount = float(coupon_amount) | |
payouts.append({"pay_date": date, "pay_val": amount}) | |
# Maturity date: include the principal as a separate payout. | |
if date == maturity_date: | |
payouts.append({"pay_date": date, "pay_val": 1000.0}) | |
return payouts | |
def main() -> None: | |
parser = argparse.ArgumentParser( | |
description="Extract information about a UA treasury bond." | |
) | |
parser.add_argument( | |
"isin", | |
nargs="+", | |
help="The ISIN to process. Multiple space-separated values are also accepted.", | |
) | |
parser.add_argument( | |
"--format", | |
choices=["tsv", "csv"], | |
default="tsv", | |
help="Specify the output format. The default is 'tsv'.", | |
) | |
args = parser.parse_args() | |
nbu_catalog_raw = download_nbu_bond_catalog(NBU_API_URL) | |
catalog = {sec["cpcode"]: sec for sec in nbu_catalog_raw} | |
for isin in args.isin: | |
if isin not in catalog: | |
print(f"Warning: bond {isin} not found in NBU API, falling back to Minfin") | |
minfin_webpage = download_minfin_isin_webpage(isin) | |
minfin_payments = parse_minfin_payout_amounts(minfin_webpage) | |
if not minfin_payments: | |
print(f"Warning: bond {isin} not found on Minfin either") | |
catalog[isin] = {"payments": minfin_payments} | |
for isin in args.isin: | |
if isin not in catalog: | |
continue | |
for payment in catalog[isin]["payments"]: | |
if args.format == "tsv": | |
print(f"{payment['pay_date']}\t{isin}\t{payment['pay_val']:.2f}") | |
elif args.format == "csv": | |
print(f"{payment['pay_date']},{isin},{payment['pay_val']:.2f}") | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment