Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Introduce short-term rating translation strategies #24

Merged
merged 3 commits into from
Nov 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Option to choose between three different strategies to translate short-term
ratings into scores and vice versa ([#24](https://github.com/hsbc/pyratings/pull/24)).

### Changed
- BREAKING CHANGE: Automatic column naming
- BREAKING CHANGE: Automatic column naming
([#9](https://github.com/hsbc/pyratings/issues/9)).
- ``get_scores_from_ratings()``
When input a ``pd.Series``, the name of the output series will now become
``ratings.name`` prefixed with "rtg_score_".
Expand All @@ -16,9 +21,14 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/
``ratings.name`` prefixed with "warf_".
When input a pd.DataFrame, the column names of the output frame will now become
``ratings.columns`` prefixed with "warf_".

- BREAKING CHANGE: Translations of short-term ratings are now different
([#16](https://github.com/hsbc/pyratings/issues/16)).

### Improved
- Splitting the code base into multiple files in order to increase maintainability.
- Splitting the code base into multiple files in order to increase maintainability
([#8](https://github.com/hsbc/pyratings/issues/8)).
- Internal checks have been improved
([#20](https://github.com/hsbc/pyratings/issues/20)).
- Documentation has been updated and will now be created via
[mkdocs](https://www.mkdocs.org/) and
[mkdocstrings](https://mkdocstrings.github.io/python/).
Expand Down
51 changes: 5 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,9 @@ _pyratings_ offers the following capabilities:
* Compute Weighted Average Rating Factor (WARF) on a portfolio level.
* Compute WARF buffer, i.e. distance from current WARF to next maxWARF.

Transformations from ratings to scores/WARF and vice versa will take place according
to the following translation table:
To get familiar with _pyratings'_ functionality, take a look at the
[Getting started](https://hsbc.github.io/pyratings/getting_started/) section of the
[documentation](https://hsbc.github.io/pyratings/).

| Moody’s | S&P | Fitch | ICE | DBRS | Bloomberg | Score | WARF | MinWARF* | MaxWARF* |
|:-------:|:----:|:-----:|:----:|:----:|:---------:|------:|------:|---------:|---------:|
| Aaa | AAA | AAA | AAA | AAA | AAA | 1 | 1 | 1 | 5 |
| Aa1 | AA+ | AA+ | AA+ | AAH | AA+ | 2 | 10 | 5 | 15 |
| Aa2 | AA | AA | AA | AA | AA | 3 | 20 | 15 | 30 |
| Aa3 | AA- | AA- | AA- | AAL | AA- | 4 | 40 | 30 | 55 |
| A1 | A+ | A+ | A+ | AH | A+ | 5 | 70 | 55 | 95 |
| A2 | A | A | A | A | A | 6 | 120 | 95 | 150 |
| A3 | A- | A- | A- | AL | A- | 7 | 180 | 150 | 220 |
| Baa1 | BBB+ | BBB+ | BBB+ | BBBH | BBB+ | 8 | 260 | 220 | 310 |
| Baa2 | BBB | BBB | BBB | BBB | BBB | 9 | 360 | 310 | 485 |
| Baa3 | BBB- | BBB- | BBB- | BBBL | BBB- | 10 | 610 | 485 | 775 |
| Ba1 | BB+ | BB+ | BB+ | BBH | BB+ | 11 | 940 | 775 | 1145 |
| Ba2 | BB | BB | BB | BB | BB | 12 | 1350 | 1145 | 1558 |
| Ba3 | BB- | BB- | BB- | BBL | BB- | 13 | 1766 | 1558 | 1993 |
| B1 | B+ | B+ | B+ | BH | B+ | 14 | 2220 | 1993 | 2470 |
| B2 | B | B | B | B | B | 15 | 2720 | 2470 | 3105 |
| B3 | B- | B- | B- | BL | B- | 16 | 3490 | 3105 | 4130 |
| Caa1 | CCC+ | CCC+ | CCC+ | CCCH | CCC+ | 17 | 4770 | 4130 | 5635 |
| Caa2 | CCC | CCC | CCC | CCC | CCC | 18 | 6500 | 5635 | 7285 |
| Caa3 | CCC- | CCC- | CCC- | CCCL | CCC- | 19 | 8070 | 7285 | 9034 |
| Ca | CC | CC | CC | CC | CC | 20 | 9998 | 9034 | 9998.5 |
| C | C | C | C | C | C | 21 | 9999 | 9998.5 | 9999.5 |
| D | D | D | D | D | DDD | 22 | 10000 | 9999.5 | 10000 |

`MinWARF` is inclusive, while `MaxWARF` is exclusive.

Short-term ratings

| Moody’s | S&P | Fitch | DBRS | Score |
|:-------:|:----:|:-----:|:----------:| -----:|
| P-1 | A-1+ | F1+ | R-1 (high) | 1 |
| | | | R-1 (mid) | 2 |
| | | | R-1 (low) | 3 |
| | A-1 | F1 | R-2 (high) | 5 |
| | | | R-2 (mid) | 6 |
| P-2 | A-2 | F2 | R-2 (low) | 7 |
| | | | R-3 (high) | 8 |
| P-3 | A-3 | F3 | R-3 (mid) | 9 |
| | | | R-3 (low) | 10 |
| NP | B | | R-4 | 12 |
| | | | R-5 | 15 |
| | C | | | 18 |
| | D | | D | 22 |
Contributions are welcome. Please read the
[Contributing](https://hsbc.github.io/pyratings/contributing/) section.
69 changes: 62 additions & 7 deletions src/pyratings/get_ratings.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def get_ratings_from_scores(
rating_scores: Union[int, float, pd.Series, pd.DataFrame],
rating_provider: Optional[Union[str, List[str]]] = None,
tenor: str = "long-term",
short_term_strategy: Optional[str] = None,
) -> Union[str, pd.Series, pd.DataFrame]:
"""Convert numerical rating scores into regular ratings.

Expand All @@ -96,6 +97,24 @@ def get_ratings_from_scores(
column names.
tenor
Should contain any valid tenor out of {"long-term", "short-term"}.
short_term_strategy
Will only be used, if `tenor` is "short-term". Choose between three distinct
strategies in order to translate a long-term rating score into a short-term
rating. Must be in {"best", "base", "worst"}.

Compare
https://hsbc.github.io/pyratings/short-term-rating/#there's-one-more-catch...

- Strategy 1 (best):
Always choose the best possible short-term rating. That's the optimistic
approach.
- Strategy 2 (base-case):
Always choose the short-term rating that a rating agency would usually assign
if there aren't any special liquidity issues (positive or negative). That's
the base-case approach.
- Strategy 3 (worst):
Always choose the worst possible short-term rating. That's the conservative
approach.

Returns
-------
Expand All @@ -109,15 +128,37 @@ def get_ratings_from_scores(

Examples
--------
Converting a single rating score:
Converting a single long-term rating score:

>>> get_ratings_from_scores(rating_scores=9, rating_provider="Fitch")
'BBB'

Converting a single short-term rating score with different `short_term_stragey`
arguments:

>>> get_ratings_from_scores(
... rating_scores=10,
... rating_provider="DBRS",
... tenor="short-term",
... short_term_strategy="best",
... )
'R-2 M'

>>> get_ratings_from_scores(
... rating_scores=10,
... rating_provider="DBRS",
... tenor="short-term",
... short_term_strategy="base",
... )
'R-2 L / R-3'

>>> get_ratings_from_scores(
... rating_scores=5, rating_provider="S&P", tenor="short-term"
... rating_scores=10,
... rating_provider="DBRS",
... tenor="short-term",
... short_term_strategy="worst",
... )
'A-1'
'R-3'

Converting a ``pd.Series`` with scores:

Expand Down Expand Up @@ -183,6 +224,13 @@ def get_ratings_from_scores(
2 D NaN D

"""
if tenor == "short-term" and short_term_strategy is None:
short_term_strategy = "base"
if tenor == "short-term" and short_term_strategy not in ["best", "base", "worst"]:
raise ValueError(
"Invalid short_term_strategy. Must be in ['best', 'base', 'worst']."
)

if isinstance(rating_scores, (int, float, np.number)):
if rating_provider is None:
raise ValueError(VALUE_ERROR_PROVIDER_MANDATORY)
Expand All @@ -193,13 +241,14 @@ def get_ratings_from_scores(
)

rtg_dict = _get_translation_dict(
"scores_to_rtg", rating_provider=rating_provider, tenor=tenor
"scores_to_rtg",
rating_provider=rating_provider,
tenor=tenor,
st_rtg_strategy=short_term_strategy,
)

if not np.isnan(rating_scores):
rating_scores = int(Decimal(f"{rating_scores}").quantize(0, ROUND_HALF_UP))
# find key (MinScore) in rtg_dict that is nearest to rating_scores
# https://bit.ly/3gdRuhX
if tenor == "long-term":
return rtg_dict.get(rating_scores, pd.NA)
else:
Expand All @@ -225,7 +274,12 @@ def get_ratings_from_scores(
valid_rtg_provider=valid_rtg_agncy[tenor],
)

rtg_dict = _get_translation_dict("scores_to_rtg", rating_provider, tenor=tenor)
rtg_dict = _get_translation_dict(
"scores_to_rtg",
rating_provider,
tenor=tenor,
st_rtg_strategy=short_term_strategy,
)

# round element to full integer, if element is number
rating_scores = rating_scores.apply(
Expand Down Expand Up @@ -270,6 +324,7 @@ def get_ratings_from_scores(
rating_scores=rating_scores[col],
rating_provider=provider,
tenor=tenor,
short_term_strategy=short_term_strategy,
)
for col, provider in zip(rating_scores.columns, rating_provider)
],
Expand Down
75 changes: 71 additions & 4 deletions src/pyratings/get_scores.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def get_scores_from_ratings(
ratings: Union[str, pd.Series, pd.DataFrame],
rating_provider: Optional[Union[str, List[str]]] = None,
tenor: str = "long-term",
short_term_strategy: Optional[str] = None,
) -> Union[int, pd.Series, pd.DataFrame]:
"""Convert regular ratings into numerical rating scores.

Expand All @@ -98,6 +99,24 @@ def get_scores_from_ratings(
column names.
tenor
Should contain any valid tenor out of {"long-term", "short-term"}
short_term_strategy
Will only be used, if `tenor` is "short-term". Choose between three distinct
strategies in order to translate a long-term rating score into a short-term
rating. Must be in {"best", "base", "worst"}.

Compare
https://hsbc.github.io/pyratings/short-term-rating/#there's-one-more-catch...

- Strategy 1 (best):
Always choose the best possible short-term rating. That's the optimistic
approach.
- Strategy 2 (base-case):
Always choose the short-term rating that a rating agency would usually assign
if there aren't any special liquidity issues (positive or negative). That's
the base-case approach.
- Strategy 3 (worst):
Always choose the worst possible short-term rating. That's the conservative
approach.

Returns
-------
Expand All @@ -117,11 +136,39 @@ def get_scores_from_ratings(

Examples
--------
Converting a single rating:
Converting a single long-term rating:

>>> get_scores_from_ratings("BBB-", "S&P", tenor="long-term")
10

Converting a single short-term rating score with different `short_term_stragey`
arguments:

>>> get_scores_from_ratings(
... ratings="P-1",
... rating_provider="Moody",
... tenor="short-term",
... short_term_strategy="best"
... )
4.0


>>> get_scores_from_ratings(
... ratings="P-1",
... rating_provider="Moody",
... tenor="short-term",
... short_term_strategy="base"
... )
3.5

>>> get_scores_from_ratings(
... ratings="P-1",
... rating_provider="Moody",
... tenor="short-term",
... short_term_strategy="worst"
... )
3.0

Converting a ``pd.Series`` of ratings:

>>> import pandas as pd
Expand Down Expand Up @@ -188,6 +235,13 @@ def get_scores_from_ratings(
2 22 NaN 22.0

"""
if tenor == "short-term" and short_term_strategy is None:
short_term_strategy = "base"
if tenor == "short-term" and short_term_strategy not in ["best", "base", "worst"]:
raise ValueError(
"Invalid short_term_strategy. Must be in ['best', 'base', 'worst']."
)

if isinstance(ratings, str):
if rating_provider is None:
raise ValueError(VALUE_ERROR_PROVIDER_MANDATORY)
Expand All @@ -197,7 +251,12 @@ def get_scores_from_ratings(
valid_rtg_provider=valid_rtg_agncy[tenor],
)

rtg_dict = _get_translation_dict("rtg_to_scores", rating_provider, tenor=tenor)
rtg_dict = _get_translation_dict(
"rtg_to_scores",
rating_provider,
tenor=tenor,
st_rtg_strategy=short_term_strategy,
)
return rtg_dict.get(ratings, pd.NA)

elif isinstance(ratings, pd.Series):
Expand All @@ -212,7 +271,12 @@ def get_scores_from_ratings(
valid_rtg_provider=valid_rtg_agncy[tenor],
)

rtg_dict = _get_translation_dict("rtg_to_scores", rating_provider, tenor=tenor)
rtg_dict = _get_translation_dict(
"rtg_to_scores",
rating_provider,
tenor=tenor,
st_rtg_strategy=short_term_strategy,
)
return pd.Series(data=ratings.map(rtg_dict), name=f"rtg_score_{ratings.name}")

elif isinstance(ratings, pd.DataFrame):
Expand All @@ -231,7 +295,10 @@ def get_scores_from_ratings(
return pd.concat(
[
get_scores_from_ratings(
ratings=ratings[col], rating_provider=provider, tenor=tenor
ratings=ratings[col],
rating_provider=provider,
tenor=tenor,
short_term_strategy=short_term_strategy,
)
for col, provider in zip(ratings.columns, rating_provider)
],
Expand Down
2 changes: 1 addition & 1 deletion src/pyratings/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def _scores_to_rtg(tenor: str, strat: str) -> Union[dict[int, str], pd.DataFrame
if tenor == "long-term":
sql_query = """
SELECT RatingScore, Rating FROM v_ltRatings
WHERE Rating != "SD" and RatingProvider=?
WHERE Rating != 'SD' and RatingProvider=?
"""
cursor.execute(sql_query, (rating_provider,))
translation_dict = dict(cursor.fetchall())
Expand Down
Loading