|
""" |
|
This module defines a class, MFRating, which provides methods for calculating |
|
the weighted rating and overall score for mutual funds based on various parameters. |
|
|
|
""" |
|
import logging |
|
from typing import List, Dict, Any |
|
import numpy as np |
|
from django.db.models import Max, Min |
|
from core.models import MutualFund, Stock |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
class MFRating: |
|
""" |
|
This class provides methods for calculating the weighted stock rank rating and overall score for mutual funds based on various parameters. |
|
""" |
|
|
|
def __init__(self, max_rank: int = 1000) -> None: |
|
self.max_rank = max_rank |
|
self.scores = { |
|
"stock_ranking_score": [10], |
|
"crisil_rank_score": [10], |
|
"churn_score": [10], |
|
"sharperatio_score": [10], |
|
"expenseratio_score": [10], |
|
"aum_score": [10], |
|
"alpha_score": [10], |
|
"beta_score": [10], |
|
} |
|
|
|
def get_weighted_score(self, values: List[float]) -> float: |
|
""" |
|
Calculates the weighted rating based on the weights and values provided. |
|
""" |
|
weights = [] |
|
values = [] |
|
for _, (weight, score) in self.scores.items(): |
|
weights.append(weight) |
|
values.append(score) |
|
|
|
return np.average(values, weights=weights) |
|
|
|
def get_rank_rating(self, stock_ranks: List[int]) -> List[float]: |
|
""" |
|
Calculates the rank rating based on the stock ranks and the maximum rank. |
|
""" |
|
return [ |
|
(self.max_rank - (rank if rank else self.max_rank)) / self.max_rank |
|
for rank in stock_ranks |
|
] |
|
|
|
def get_overall_score(self, **kwargs) -> float: |
|
""" |
|
It returns the overall weighted score for mutual funds based on various parameters. |
|
|
|
""" |
|
|
|
stock_rankings = self.get_rank_rating(kwargs.get("stock_rankings")) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.scores["stock_ranking_score"].append( |
|
np.average(stock_rankings, weights=kwargs.get("stock_weights")) |
|
) |
|
self.scores["alpha_score"].append(kwargs.get("alpha", 0) / 100) |
|
self.scores["beta_score"].append((2 - kwargs.get("beta", 2)) / 2) |
|
self.scores["crisil_rank_score"].append( |
|
(kwargs.get("crisil_rank_score", 0)) / 5 |
|
) |
|
self.scores["churn_score"].append(kwargs.get("churn_rate", 0) / 100) |
|
self.scores["sharperatio_score"].append(kwargs.get("sharpe_ratio", 0) / 100) |
|
self.scores["expenseratio_score"].append(kwargs.get("expense_ratio", 0) / 100) |
|
max_aum, min_aum, aum = kwargs.get("aum_score", (1, 0, 0)) |
|
self.scores["aum_score"].append((aum - min_aum) / (max_aum - min_aum)) |
|
|
|
|
|
return self.get_weighted_score(self.scores) |
|
|
|
|
|
class MutualFundScorer: |
|
def __init__(self) -> None: |
|
self.mf_scores = [] |
|
|
|
def _get_stock_ranks(self, isin_ids: List[str]) -> List[int]: |
|
"""Get stock ranks based on ISIN ids.""" |
|
|
|
return list( |
|
Stock.objects.filter(isin_number__in=isin_ids) |
|
.order_by("rank") |
|
.values_list("rank", "isin_number") |
|
) |
|
|
|
def _get_mutual_funds(self) -> List[MutualFund]: |
|
"""Get a list of top 30 mutual funds based on rank.""" |
|
|
|
return MutualFund.objects.exclude(rank=None).order_by("rank")[:30] |
|
|
|
def _get_risk_measure( |
|
self, risk_measures: Dict[str, Any], key: str, year: str |
|
) -> float: |
|
""" |
|
Get value of the specified key from the risk_measures dictionary for the given year. |
|
""" |
|
try: |
|
value = risk_measures.get(year, {}).get(key, 0) |
|
return float(value) |
|
except (TypeError, ValueError): |
|
return 0 |
|
|
|
def _get_most_non_null_key(self, key, mutual_funds): |
|
""" |
|
Get the year with the maximum number of non-None values for the specified key |
|
within the given mutual funds. |
|
""" |
|
year_counts = { |
|
"for15Year": 0, |
|
"for10Year": 0, |
|
"for5Year": 0, |
|
"for3Year": 0, |
|
"for1Year": 0, |
|
} |
|
|
|
for mf in mutual_funds: |
|
risk_measures = mf.data["risk_measures"].get("fundRiskVolatility", {}) |
|
|
|
for year in year_counts: |
|
if risk_measures.get(year, {}).get(key) is not None: |
|
year_counts[year] += 1 |
|
|
|
most_non_null_year = max(year_counts, key=year_counts.get) |
|
return most_non_null_year |
|
|
|
def get_scores(self) -> List[Dict[str, Any]]: |
|
"""Calculate scores for mutual funds and return the results.""" |
|
|
|
logger.info("Calculating scores for mutual funds...") |
|
max_aum = MutualFund.objects.exclude(rank=None).aggregate(max_price=Max("aum"))[ |
|
"max_price" |
|
] |
|
min_aum = MutualFund.objects.exclude(rank=None).aggregate(min_price=Min("aum"))[ |
|
"min_price" |
|
] |
|
mutual_funds = self._get_mutual_funds() |
|
|
|
|
|
sharpe_ratio_year = self._get_most_non_null_key("sharpeRatio", mutual_funds) |
|
alpha_year = self._get_most_non_null_key("alpha", mutual_funds) |
|
beta_year = self._get_most_non_null_key("beta", mutual_funds) |
|
for mf in mutual_funds: |
|
mf_rating = MFRating( |
|
max_rank=1000, |
|
) |
|
logger.info(f"Processing mutual fund: %s", mf.fund_name) |
|
holdings = ( |
|
mf.data.get("holdings", {}) |
|
.get("equityHoldingPage", {}) |
|
.get("holdingList", []) |
|
) |
|
portfolio_holding_weights = { |
|
holding.get("isin"): ( |
|
holding.get("weighting") if holding.get("weighting") else 0 |
|
) |
|
for holding in holdings |
|
if holding.get("isin") |
|
} |
|
stock_ranks_and_weights = [ |
|
(rank, portfolio_holding_weights[isin]) |
|
for rank, isin in self._get_stock_ranks( |
|
portfolio_holding_weights.keys() |
|
) |
|
] |
|
stock_ranks, stock_weights = zip(*stock_ranks_and_weights) |
|
sharpe_ratio = self._get_risk_measure( |
|
mf.data["risk_measures"].get("fundRiskVolatility", {}), |
|
"sharpeRatio", |
|
sharpe_ratio_year, |
|
) |
|
alpha = self._get_risk_measure( |
|
mf.data["risk_measures"].get("fundRiskVolatility", {}), |
|
"alpha", |
|
alpha_year, |
|
) |
|
beta = self._get_risk_measure( |
|
mf.data["risk_measures"].get("fundRiskVolatility", {}), |
|
"beta", |
|
beta_year, |
|
) |
|
overall_score = mf_rating.get_overall_score( |
|
stock_rankings=stock_ranks, |
|
stock_weights=stock_weights, |
|
churn_rate=mf.data["quotes"]["lastTurnoverRatio"] |
|
if mf.data["quotes"].get("lastTurnoverRatio") |
|
else 0, |
|
sharpe_ratio=sharpe_ratio, |
|
expense_ratio=mf.data["quotes"]["expenseRatio"], |
|
crisil_rank_score=mf.crisil_rank, |
|
aum_score=(max_aum, min_aum, mf.aum), |
|
alpha=alpha, |
|
beta=beta, |
|
) |
|
|
|
self.mf_scores.append( |
|
{ |
|
"isin": mf.isin_number, |
|
"name": mf.fund_name, |
|
"rank": mf.rank, |
|
"sharpe_ratio": round(sharpe_ratio, 4), |
|
"churn_rate": mf.data["quotes"].get("lastTurnoverRatio", 0), |
|
"expense_ratio": mf.data["quotes"].get("expenseRatio", 0), |
|
"aum": mf.aum, |
|
"alpha": round(alpha, 4), |
|
"beta": round(beta, 4), |
|
"crisil_rank": mf.crisil_rank, |
|
"overall_score": round(overall_score, 4), |
|
} |
|
) |
|
logger.info("Finished calculating scores.") |
|
return sorted(self.mf_scores, key=lambda d: d["overall_score"], reverse=True) |
|
|