from __future__ import annotations

from random import randint
from typing import Optional, List, Union, Tuple, Literal
from urllib.parse import urlencode

import numpy as np
import pandas as pd

import scipy.stats

import okama.portfolios.dcf as dcf
from okama import settings
from okama.common.helpers.rebalancing import Rebalance
from okama.common import make_asset_list, validators
from okama.common.helpers import helpers, ratios


class Portfolio(make_asset_list.ListMaker):
    """
    Investment portfolio.

    An investment portfolio is a type of financial asset (same as stocks, ETFs, mutual funds, currencies, etc.).
    The arguments are similar to `AssetList`, but `Portfolio` additionally has:

    - `weights`
    - `rebalancing_strategy`
    - `symbol`

    Portfolio is defined by the investment strategy, which includes:

    - asset allocation (financial assets and their proportions in the portfolio)
    - the rebalancing strategy (`rebalancing_strategy` parameter)

    Rebalancing is the action of bringing the portfolio that has deviated away from the original target asset
    allocation back into line. After rebalancing, the portfolio assets have the original weights.

    Parameters
    ----------
    assets : list, default None
        List of assets. Could include tickers or asset-like objects (`Asset`, `Portfolio`).
        If None, a single-asset list with a default ticker is used.
    first_date : str, default None
        First date of monthly return time series.
        If None, the first date is calculated automatically as the oldest available date for the listed assets.
    last_date : str, default None
        Last date of monthly return time series.
        If None, the last date is calculated automatically as the newest available date for the listed assets.
    ccy : str, default 'USD'
        Base currency for the list of assets. All risk metrics and returns are adjusted to the base currency.
    inflation : bool, default True
        Defines whether to take inflation data into account in the calculations.
        Including inflation could limit available data (`first_date`, `last_date`)
        as the inflation data is usually published with a one-month delay.
        With `inflation=False`, some properties like real return are not available.
    weights : list of float, default None
        List of asset weights.
        The weight of an asset is the percent of an investment portfolio that corresponds to the asset.
        If None, an equally weighted portfolio is created (all weights are equal).
    rebalancing_strategy : Rebalance, default Rebalance(period="month")
        Rebalancing strategy for an investment portfolio. The strategy is defined by:

        - period (rebalancing frequency): predetermined time intervals when the investor rebalances the portfolio.
          If "none", asset weights are not rebalanced.
        - abs_deviation: the absolute deviation allowed for the asset weights in the portfolio.
        - rel_deviation: the relative deviation allowed for the asset weights in the portfolio.
    symbol : str, default None
        Text symbol of portfolio. It is similar to tickers but have a namespace information.
        Portfolio symbol must end with .PF (all_weather_portfolio.PF).
        If None, a random symbol is generated (portfolio_7802.PF).
    """

    def __init__(
        self,
        assets: Optional[List[str]] = None,
        *,
        first_date: Optional[str] = None,
        last_date: Optional[str] = None,
        ccy: str = "USD",
        inflation: bool = True,
        weights: Optional[List[float]] = None,
        rebalancing_strategy: Rebalance = Rebalance(period="month"),
        symbol: str = None,
    ):
        super().__init__(
            assets,
            first_date=first_date,
            last_date=last_date,
            ccy=ccy,
            inflation=inflation,
        )
        self.weights = weights
        self.assets_weights = dict(zip(self.symbols, self.weights))
        self.rebalancing_strategy = rebalancing_strategy
        self.symbol = symbol or f"portfolio_{randint(1000, 9999)}.PF"
        self._ror = pd.DataFrame(dtype=float)
        self.dcf = dcf.PortfolioDCF(self)

    def __repr__(self):
        dic = {
            "symbol": self.symbol,
            "assets": self.symbols,
            "weights": self.weights,
            "rebalancing_period": self.rebalancing_strategy.period,
            "rebalancing_abs_deviation": self.rebalancing_strategy.abs_deviation,
            "rebalancing_rel_deviation": self.rebalancing_strategy.rel_deviation,
            "currency": self.currency,
            "inflation": self.inflation if hasattr(self, "inflation") else "None",
            "first_date": self.first_date.strftime("%Y-%m"),
            "last_date": self.last_date.strftime("%Y-%m"),
            "period_length": self._pl_txt,
        }
        return repr(pd.Series(dic))

    def _add_inflation(self):
        if hasattr(self, "inflation"):
            return pd.concat([self.ror, self.inflation_ts], axis=1, join="inner", copy="false")
        else:
            return self.ror

    def _clear_cache(self):
        self._ror = pd.DataFrame(dtype=float)
        try:
            self.dcf._wealth_index_fv = pd.DataFrame()
            self.dcf._monte_carlo_wealth_fv = pd.DataFrame()
            self.dcf._cash_flow_fv = pd.DataFrame()
        except AttributeError:
            pass

    # todo: add setters for dates and ccy

    @property
    def weights(self) -> Union[list, tuple]:
        """
        Assets weights in portfolio.

        If not defined equal weights are used for each asset.

        Weights must be a list (or tuple) of float values.

        Returns
        -------
        list or tuple
            Values for the weights of assets in portfolio.

        Examples
        --------
        >>> x = ok.Portfolio(['SPY.US', 'BND.US'])
        >>> x.weights
        [0.5, 0.5]
        """
        return self._weights

    @weights.setter
    def weights(self, weights: Optional[List[float]]):
        if weights is None:
            # Equally weighted portfolio
            n = len(self.symbols)  # number of assets
            weights = list(np.repeat(1 / n, n))
        else:
            [validators.validate_real("weight", weight) for weight in weights]
            helpers.Frame.weights_sum_is_one(weights)
            if len(weights) != len(set(self.symbols)):
                raise ValueError(
                    f"Number of tickers ({len(set(self.symbols))}) should be equal "
                    f"to the weights number ({len(weights)})"
                )
        self._clear_cache()
        self._weights = weights

    @property
    def weights_ts(self) -> pd.DataFrame:
        """
        Calculate assets weights time series considering rebalancing strategy.

        The weights of assets in Portfolio are not constant if rebalancing_period is different from 'month'.

        Returns
        -------
        DataFrame
            Weights of assets time series.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> reb_period = ok.Rebalance(period="none")  # The Portfolio is not rebalanced.
        >>> pf = ok.Portfolio(['SPY.US', 'AGG.US'], weights=[0.5, 0.5], rebalancing_strategy=reb_period)
        >>> pf.weights_ts.plot()
        >>> plt.show()

        The weights of assets time series will differ significantly if the portfolio rebalancing_period is 1 year.

        >>> pf.rebalancing_strategy = ok.Rebalance(period="year")  # set a new rebalancing period
        >>> pf.weights_ts.plot()
        >>> plt.show()
        """
        if self._condition_for_rebalancing:
            return self.rebalancing_strategy.assets_weights_ts(
                ror=self.assets_ror,
                target_weights=self.weights,
            )
        # Fast calculation
        values = np.tile(self.weights, (self.ror.shape[0], 1))
        return pd.DataFrame(values, index=self.ror.index, columns=self.symbols)

    @property
    def rebalancing_events(self) -> pd.DataFrame:
        """
        Time series with the dates of rebalancing events.

        Each event has the type of rebalancing event:
        - calendar (calendar event)
        - abs (rebalancing by absolute deviation)
        - rel (rebalancing by relative deviation)

        Returns
        -------
        DataFrame
            Dates of rebalancing events time series.

        Examples
        --------
        >>> pf = ok.Portfolio(
        ...     assets=["SPY.US", "AGG.US"],
        ...     weights=[0.5, 0.5],
        ...     rebalancing_strategy=ok.Rebalance(period="year", abs_deviation=0.05)
        ... )
        >>> pf.rebalancing_events
        date
        2004-05    calendar
        2005-05    calendar
        2006-05    calendar
        2007-05    calendar
        2008-05    calendar
                     ...
        2020-05    calendar
        2020-11         abs
        2021-05    calendar
        2022-05    calendar
        2023-05    calendar
        Name: event, Length: 21, dtype: object
        """
        return self.rebalancing_strategy.wealth_ts(target_weights=self.weights, ror=self.assets_ror).events

    @property
    def rebalancing_strategy(self) -> Rebalance:
        """
        Return rebalancing strategy of the portfolio.

        Rebalancing is the process by which an investor restores their portfolio to its target allocation
        by selling and buying assets. After rebalancing all the assets have original weights.

        Rebalancing period (rebalancing frequency) is predetermined time intervals when
        the investor rebalances the portfolio.

        Returns
        -------
        Rebalance
            Portfolio rebalancing strategy.

        Examples
        --------
        >>> pf = ok.Portfolio(
        ...     assets=["SPY.US", "AGG.US"],
        ...     weights=[0.5, 0.5],
        ...     rebalancing_strategy=ok.Rebalance(period="year", abs_deviation=0.05)
        ... )
        >>> pf.rebalancing_strategy
        Rebalance(period='year', abs_deviation=0.05, rel_deviation=None)
        """
        return self._rebalancing_strategy

    @rebalancing_strategy.setter
    def rebalancing_strategy(self, rebalancing_strategy: Rebalance):
        if isinstance(rebalancing_strategy, Rebalance):
            self._clear_cache()
            self._rebalancing_strategy = rebalancing_strategy
        else:
            raise ValueError("rebalancing_strategy must be of type Rebalance")

    @property
    def _condition_for_rebalancing(self) -> bool:
        """
        Verify whether assets weights are constant
        The weights are constant only if the period is 'month' and no conditional rebalancing.
        """
        return (
            self.rebalancing_strategy.period != "month"
            or self.rebalancing_strategy.abs_deviation is not None
            or self.rebalancing_strategy.rel_deviation is not None
        )

    @property
    def symbol(self) -> str:
        """
        Return a text symbol of portfolio.

        Symbols are similar to tickers but have a namespace information:

        * SPY.US is a symbol
        * SPY is a ticker

        Portfolios have '.PF' as a namespace.

        Returns
        -------
        str
            Text symbol of the portfolio.

        Examples
        --------
        >>> p = ok.Portfolio()
        >>> p.symbol  # a randomly generated symbol will be shown
        'portfolio_5312.PF'

        >>> p.symbol = 'spy_portfolo.PF'  # The symbol can be customized after initialization

        New Portfolio can have a custom symbol.

        >>> p = ok.Portfolio(symbol='aggressive.PF')
        >>> p.symbol
        'aggressive.PF'
        """
        return self._symbol

    @symbol.setter
    def symbol(self, text_symbol: str):
        if isinstance(text_symbol, str) and text_symbol.endswith(".PF"):
            if " " in text_symbol:
                raise ValueError("portfolio text symbol should not have whitespace characters.")
            self._clear_cache()
            self._symbol = text_symbol
        else:
            raise ValueError('portfolio symbol must be a string ending with ".PF" namespace.')

    @property
    def name(self) -> str:
        """
        Return text name of portfolio.

        For Portfolio name is equal to symbol.

        Returns
        -------
        str
            Text name of the portfolio.

        >>> p = ok.Portfolio()
        >>> p.name
        'portfolio_5312.PF'
        """
        return self.symbol

    @property
    def ror(self) -> pd.Series:
        """
        Calculate monthly rate of return time series for portfolio considering rebalancing strategy.

        Returns
        -------
        Series
            Rate of return monthly time series for the portfolio.

        Examples
        --------
        >>> pf = ok.Portfolio(first_date='2020-01', last_date='2020-12')
        >>> pf.ror
        Date
        2020-01   -0.0004
        2020-02   -0.0792
        2020-03   -0.1249
        2020-04    0.1270
        2020-05    0.0476
        2020-06    0.0177
        2020-07    0.0589
        2020-08    0.0698
        2020-09   -0.0374
        2020-10   -0.0249
        2020-11    0.1088
        2020-12    0.0370
        Freq: M, Name: portfolio_4669.PF, dtype: float64

        >>> import matplotlib.pyplot as plt
        >>> pf.ror.plot(kind='bar')
        >>> plt.show()
        """
        if self._ror.empty:
            if not self._condition_for_rebalancing:
                # Fast calculation
                s = helpers.Frame.get_portfolio_return_ts(self.weights, self.assets_ror)
            else:
                s = self.rebalancing_strategy.return_ror_ts(self.weights, self.assets_ror)
            s.rename(self.symbol, inplace=True)
            self._ror = s
        return self._ror

    @property
    def wealth_index(self) -> pd.DataFrame:
        """
        Calculate wealth index time series for the portfolio and accumulated inflation.

        Wealth index (Cumulative Wealth Index) is a time series that presents the value of portfolio over
        historical time period. Accumulated inflation time series is added if `inflation=True` in the Portfolio.

        Wealth index is obtained from the accumulated return multiplied by the initial investments.
        That is: 1000 * (Acc_Return + 1)
        Initial investments are taken as 1000 units of the Portfolio base currency.

        Returns
        -------
        DataFrame
            Time series of wealth index values for portfolio and accumulated inflation.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> x = ok.Portfolio(['SPY.US', 'BND.US'])
        >>> x.wealth_index.plot()
        >>> plt.show()
        """
        df = self._add_inflation()
        iv = settings.DEFAULT_INITIAL_INVESTMENT
        df = helpers.Frame.get_wealth_indexes(ror=df, initial_amount=iv)
        df = self._make_df_if_series(df)
        return df

    def _make_df_if_series(self, ts):
        if isinstance(ts, pd.Series):  # should always return a DataFrame
            ts = ts.to_frame()
            ts.rename({1: self.symbol}, axis="columns", inplace=True)
        return ts

    @property
    def wealth_index_with_assets(self) -> pd.DataFrame:
        """
        Calculate wealth index time series for the portfolio, all assets and accumulated inflation.

        Cash flows are not taken into account.

        Wealth index (Cumulative Wealth Index) is a time series that presents the value of portfolio over
        historical time period. Accumulated inflation time series is added if `inflation=True` in the Portfolio.

        Wealth index is obtained from the accumulated return multiplied by the initial investments.
        initial_amount_pv * (Acc_Return + 1)

        Returns
        -------
        DataFrame
            Time series of wealth index values for portfolio, each asset and accumulated inflation.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> pf = ok.Portfolio(['VOO.US', 'GLD.US'], weights=[0.8, 0.2])
        >>> pf.wealth_index_with_assets.plot()
        >>> plt.show()
        """
        ls = [self.ror, self.assets_ror]
        if hasattr(self, "inflation"):
            ls.append(self.inflation_ts)
        df = pd.concat(ls, axis=1, join="inner", copy="false")
        return helpers.Frame.get_wealth_indexes(df)

    @property
    def mean_return_monthly(self) -> float:
        """
        Calculate monthly mean return (arithmetic mean) for the portfolio rate of return time series.

        Mean return calculated for the full history period.

        Returns
        -------
        float
            Mean return value.

        Examples
        --------
        >>> pf = ok.Portfolio(['ISF.LSE', 'XGLE.LSE'], weights=[0.6, 0.4], ccy='GBP')
        >>> pf.mean_return_monthly
        0.0001803312727272665
        """
        return self.ror.mean()

    @property
    def mean_return_annual(self) -> float:
        """
        Calculate annualized mean return (arithmetic mean) for the portfolio rate of return time series.

        Mean return calculated for the full history period.

        Returns
        -------
        float
            Mean return value.

        Examples
        --------
        >>> pf = ok.Portfolio(['XCS6.XETR', 'PHAU.LSE'], weights=[0.85, 0.15], ccy='USD')
        >>> pf.names
        {'XCS6.XETR': 'Xtrackers MSCI China UCITS ETF 1C', 'PHAU.LSE': 'WisdomTree Physical Gold'}
        >>> pf.mean_return_annual
        0.09005826844072184
        """
        return self.mean_return_monthly * settings._MONTHS_PER_YEAR

    def annual_return_ts(self, return_type: Literal["cagr", "arithmetic_mean"] = "cagr") -> pd.Series:
        """
        Calculate annual rate of return time series for portfolio.

        Rate of return is calculated for each calendar year.

        Parameters
        ----------
        return_type : {'cagr', 'arithmetic_mean'}, default 'cagr'
            Method used to calculate annual returns.

        Returns
        -------
        Series
            Calendar annual rate of return time series.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> pf = ok.Portfolio(['VOO.US', 'AGG.US'], weights=[0.4, 0.6])
        >>> pf.annual_return_ts().plot(kind='bar')
        >>> plt.show()

        Plot annual returns for portfolio with EUR as the base currency.

        >>> pf = ok.Portfolio(['VOO.US', 'AGG.US'], weights=[0.4, 0.6], ccy='EUR')
        >>> pf.annual_return_ts().plot(kind='bar')
        >>> plt.show()
        """
        return helpers.Frame.get_annual_return_ts_from_monthly(self.ror, return_type)

    def get_cagr(self, period: Optional[int] = None, real: bool = False) -> pd.Series:
        """
        Calculate portfolio Compound Annual Growth Rate (CAGR) for a given trailing period.

        Compound annual growth rate (CAGR) is the rate of return that would be required for an investment to grow from
        its initial to its final value, assuming all incomes were reinvested.

        Inflation adjusted annualized returns (real CAGR) are shown with `real=True` option.

        Annual inflation value is calculated for the same period if `inflation=True` in the `Portfolio`.

        Parameters
        ----------
        period : int, default None
            CAGR trailing period in years. If None, use the full available period.
        real : bool, default False
            CAGR is adjusted for inflation (real CAGR) if True.
            Portfolio should be initiated with `inflation=True` for real CAGR.

        Returns
        -------
        Series
            Portfolio CAGR value and annualized inflation (optional).

        Notes
        -----
        CAGR is not defined for periods less than 1 year (NaN values are returned).

        Examples
        --------
        >>> pf = ok.Portfolio(['XCS6.XETR', 'PHAU.LSE'], weights=[0.85, 0.15], ccy='USD')
        >>> pf.names
        {'XCS6.XETR': 'Xtrackers MSCI China UCITS ETF 1C', 'PHAU.LSE': 'WisdomTree Physical Gold'}

        To get inflation adjusted return (real annualized return) add `real=True` option:

        >>> pf.get_cagr(period=5, real=True)
        portfolio_5625.PF    0.121265
        dtype: float64
        """
        # TODO: add option assets=False
        ts = self._add_inflation()
        df = self._make_df_if_series(ts)
        dt0 = self.last_date
        if period is None:
            dt = self.first_date
        else:
            self._validate_period(period)
            dt = helpers.Date.subtract_years(dt0, period)
        cagr = helpers.Frame.get_cagr(df[dt:])
        if real:
            if not hasattr(self, "inflation"):
                raise ValueError("Real CAGR is not defined. Set inflation=True in Portfolio to calculate it.")
            mean_inflation = helpers.Frame.get_cagr(self.inflation_ts[dt:])
            cagr = (1.0 + cagr) / (1.0 + mean_inflation) - 1.0
            cagr.drop(self.inflation, inplace=True)
        return cagr

    def get_rolling_cagr(self, window: int = 12, real: bool = False) -> pd.DataFrame:
        """
        Calculate rolling CAGR (Compound Annual Growth Rate) for the portfolio.

        Parameters
        ----------
        window : int, default 12
            Size of the moving window in months. Window size should be at least 12 months for CAGR.
        real : bool, default False
            CAGR is adjusted for inflation (real CAGR) if True.
            Portfolio should be initiated with Inflation=True for real CAGR.

        Returns
        -------
        DataFrame
            Time series of rolling CAGR and mean inflation (optionally).

        Notes
        -----
        CAGR is not defined for periods less than 1 year (NaN values are returned).

        Examples
        --------
        >>> x = ok.Portfolio(['SPY.US', 'BND.US'], ccy='EUR', inflation=True)
        >>> x.get_rolling_cagr(window=5*12, real=True)
                 portfolio_...
        date
        ...             ...
        """
        df_or_ts = self._add_inflation()
        if real:
            df_or_ts = self._make_real_return_time_series(df_or_ts)
        df = self._make_df_if_series(df_or_ts)
        return helpers.Frame.get_rolling_fn(df, window=window, fn=helpers.Frame.get_cagr)

    def get_monthly_geometric_mean_return(self):
        """
        Calculate monthly geometric mean return for the portfolio.

        The geometric mean return is the constant periodic return that would produce the same
        final value as a sequence of varying periodic returns when compounded over time.

        Returns
        -------
        float
            Monthly geometric mean return value.

        Examples
        --------
        >>> pf = ok.Portfolio(['SPY.US', 'AGG.US'], weights=[0.6, 0.4])
        >>> pf.get_monthly_geometric_mean_return()
        0.005321
        """
        return (self.ror + 1.0).prod() ** (1 / self.ror.shape[0]) - 1.0

    def get_cumulative_return(self, period: Union[str, int, None] = None, real: bool = False) -> pd.Series:
        """
        Calculate cumulative return over a given trailing period for the portfolio.

        The cumulative return is the total change in the portfolio price during the investment period.

        Inflation adjusted cumulative returns (real cumulative returns) are shown with `real=True` option.
        Annual inflation data is calculated for the same period if `inflation=True` in the `Portfolio`.

        Parameters
        ----------
        period : str or int or None, default None
            Trailing period in years.
            None - full time cumulative return.
            'YTD' - (Year To Date) period of time beginning the first day of the calendar year up to the last month.
        real : bool, default False
            Cumulative return is adjusted for inflation (real cumulative return) if True.
            Portfolio should be initiated with `Inflation=True` for real cumulative return.

        Returns
        -------
        Series
            Cumulative rate of return values for portfolio and cumulative inflation (if inflation=True in Portfolio).

        Examples
        --------
        >>> pf = ok.Portfolio(['BTC-USD.CC', 'LTC-USD.CC'], weights=[.8, .2], last_date='2021-03')
        >>> pf.get_cumulative_return(period=2, real=True)
        portfolio_6232.PF    9.39381
        dtype: float64
        """
        ts = self._add_inflation()
        df = self._make_df_if_series(ts)
        dt0 = self.last_date

        if period is None:
            dt = self.first_date
        elif str(period).lower() == "ytd":
            year = dt0.year
            dt = str(year)
        else:
            self._validate_period(period)
            dt = helpers.Date.subtract_years(dt0, period)

        cr = helpers.Frame.get_cumulative_return(df[dt:])
        if real:
            if not hasattr(self, "inflation"):
                raise ValueError(
                    "Real cumulative return is not defined (no inflation information is available)."
                    "Set inflation=True in Portfolio to calculate it."
                )
            cumulative_inflation = helpers.Frame.get_cumulative_return(self.inflation_ts[dt:])
            cr = (1.0 + cr) / (1.0 + cumulative_inflation) - 1.0
            cr.drop(self.inflation, inplace=True)
        return cr

    def get_rolling_cumulative_return(self, window: int = 12, real: bool = False) -> pd.DataFrame:
        """
        Calculate rolling cumulative return.

        The cumulative return is the total change in the portfolio price.

        Parameters
        ----------
        window : int, default 12
            Size of the moving window in months.
        real : bool, default False
            Cumulative return is adjusted for inflation (real cumulative return) if True.
            Portfolio should be initiated with `Inflation=True` for real cumulative return.

        Returns
        -------
        DataFrame
            Time series of rolling cumulative return and inflation (optional).

        Examples
        --------
        >>> pf = ok.Portfolio(
        ...     ['SPY.US', 'AGG.US', 'GLD.US'],
        ...     weights=[.6, .35, .05],
        ...     rebalancing_strategy=ok.Rebalance(period="year"),
        ... )
        >>> pf.get_rolling_cumulative_return(window=24, real=True)
                 portfolio_9012.PF
        2006-11           0.125728
        2006-12           0.104348
        2007-01           0.129601
        2007-02           0.110680
        2007-03           0.132610
                            ...
        2021-03           0.263755
        2021-04           0.275474
        2021-05           0.322736
        2021-06           0.264963
        2021-07           0.273801
        [177 rows x 1 columns]
        """
        ts = self._add_inflation()
        if real:
            ts = self._make_real_return_time_series(ts)
        df = self._make_df_if_series(ts)
        return helpers.Frame.get_rolling_fn(
            df,
            window=window,
            fn=helpers.Frame.get_cumulative_return,
            window_below_year=True,
        )

    @property
    def assets_close_monthly(self) -> pd.DataFrame:
        """
        Show assets monthly close time series adjusted to the base currency.

        Returns
        -------
        DataFrame
            Assets monthly close time series adjusted to the base currency.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD')
        >>> pf.assets_close_monthly.plot()
        >>> plt.show()
        """
        series_list = []  # Collect all series to concatenate once at the end
        for x in self.asset_obj_dict.values():
            series = (
                x.close_monthly
                if x.currency == self.currency
                else self._adjust_price_to_currency_monthly(x.close_monthly, x.currency)
            )
            series = series.rename(x.symbol)
            series_list.append(series)
        if len(series_list) == 1:
            assets_close_monthly = series_list[0].to_frame()
        else:
            assets_close_monthly = pd.concat(series_list, axis=1, join="inner", copy="false")
        assets_close_monthly = assets_close_monthly[self.first_date : self.last_date]
        return assets_close_monthly

    @property
    def close_monthly(self) -> pd.Series:
        """
        Portfolio size monthly time series.

        Portfolio size is shown in base currency units. It is similar to the close value of an asset.
        Initial portfolio value is equal to 1000 units of base currency.

        Returns
        -------
        pd.Series
            Monthly portfolio size time series.

        Notes
        -----
        'close_mothly' shows the same output as the 'wealth_index'.
        This property is required as Portfolio must have the same attributes as an Asset.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD')
        >>> pf.close_monthly.plot()
        >>> plt.show()
        """
        return self.wealth_index.iloc[:, 0]

    @property
    def number_of_securities(self) -> pd.DataFrame:
        """
        Calculate the number of securities monthly time series for the portfolio assets.

        The number of securities in the Portfolio is changing over time as the dividends are reinvested.
        Portfolio rebalancing also affects the number of securities.

        Initial number of securities depends on the portfolio size in base currency (1000 units).

        Returns
        -------
        DataFrame
            Number of securities monthly time series for the portfolio assets.

        Examples
        --------
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD', last_date='07-2021')
        >>> pf.number_of_securities
                   SPY.US     BND.US
        Date
        2007-05  3.261153   6.687174
        2007-06  3.337216   6.758447
        2007-07  3.407015   6.643519
        2007-08  3.410268   6.663862
        2007-09  3.372630   6.798730
                   ...        ...
        2021-03  3.273521  15.313911
        2021-04  3.204779  15.685601
        2021-05  3.196768  15.749127
        2021-06  3.186124  15.879056
        2021-07  3.166335  16.003569
        [171 rows x 2 columns]
        """
        df = self.weights_ts.mul(self.wealth_index.iloc[:, 0], axis=0).div(self.assets_close_monthly, axis=0)
        return helpers.Frame.change_columns_order(df, self.symbols)

    @property
    def dividends(self) -> pd.Series:
        """
        Calculate portfolio dividends monthly time series.

        Portfolio dividends are obtained by summing asset dividends adjusted to the base currency.
        Dividends size depends on the portfolio value and number of securities.

        Returns
        -------
        Series
            Portfolio dividends monthly time series.

        Examples
        --------
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD', last_date='07-2021')
        >>> pf.dividends
        2007-05    0.849271
        2007-06    3.928855
        2007-07    1.551262
        2007-08    2.023148
        2007-09    4.423416
                     ...
        2021-03    6.155337
        2021-04    3.019478
        2021-05    2.056836
        2021-06    6.519521
        2021-07    2.114071
        Freq: M, Name: portfolio_2951.PF, Length: 171, dtype: float64
        """
        s = (self._get_assets_dividends() * self.number_of_securities).sum(axis=1)
        s.rename(self.symbol, inplace=True)
        return s

    @property
    def dividend_yield(self) -> pd.Series:
        """
        Calculate last twelve months (LTM) dividend yield time series for the portfolio. Time series has monthly values.

        Portfolio dividend yield is a weighted sum of the assets dividend yields (adjusted to
        the portfolio base currency).

        For an asset LTM dividend yield is the sum trailing twelve months of common dividends per share divided by
        the current price per share.

        Returns
        -------
        Series
            Portfolio LTM dividend yield monthly time series.

        Examples
        --------
        >>> pf = ok.Portfolio(['T.US', 'XOM.US'], weights=[0.8, 0.2], first_date='2010-01', last_date='2021-01', ccy='USD')
        >>> pf.dividend_yield
        2010-01    0.013249
        2010-02    0.014835
        2010-03    0.014257
                     ...
        2020-11    0.076132
        2020-12    0.074743
        2021-01    0.073643
        Freq: M, Name: portfolio_8836.PF, Length: 133, dtype: float64

        >>> import matplotlib.pyplot as plt
        >>> pf.dividend_yield.plot()
        >>> plt.show()
        """
        df = self._assets_dividend_yield @ self.weights_ts.T
        div_yield_series = pd.Series(np.diag(df), index=df.index)  # faster than df1.mul(df2).sum(axis=1)
        div_yield_series.rename(self.symbol, inplace=True)
        return div_yield_series

    @property
    def dividends_annual(self) -> pd.DataFrame:
        """
        Return calendar year dividends sum time series for each asset.

        Returns
        -------
        DataFrame
            Annual dividends time series for each asset.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD', last_date='07-2021')
        >>> pf.dividends_annual.plot(kind='bar')
        >>> plt.show()
        """
        return self._get_assets_dividends().resample("Y").sum()

    @property
    def dividend_yield_annual(self):
        """
        Calculate last twelve months (LTM) dividend yield annual time series.

        Time series is based on the dividend yield for the end of calendar year.

        LTM dividend yield is the sum trailing twelve months of common dividends per share divided by
        the current price per share.

        All yields are calculated in the asset list base currency after adjusting the dividends and price time series.
        Forecasted (future) dividends are removed.

        Returns
        -------
        DataFrame
            Time series of LTM dividend yield for each asset.

        See Also
        --------
        dividend_yield : Dividend yield time series.
        dividends_annual : Calendar year dividends time series.
        dividend_paying_years : Number of years of consecutive dividend payments.
        dividend_growing_years : Number of years when the annual dividend was growing.
        get_dividend_mean_yield : Arithmetic mean for annual dividend yield.
        get_dividend_mean_growth_rate : Geometric mean of annual dividends growth rate.

        Examples
        --------
        >>> import matplotlib.pyplot as plt
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD', last_date='07-2021')
        >>> pf.dividend_yield_annual.plot(kind='bar')
        >>> plt.show()
        """
        return self._assets_dividend_yield.resample(rule="Y").last()

    @property
    def assets_dividend_yield(self):
        """
        Calculate last twelve months (LTM) dividend yield time series (monthly) for each asset.

        LTM dividend yield is the sum trailing twelve months of common dividends per share divided by
        the current price per share.

        All yields are calculated in the asset list base currency after adjusting the dividends and price time series.
        Forecasted (future) dividends are removed.
        Zero value time series are created for assets without dividends.

        Returns
        -------
        DataFrame
            Monthly time series of LTM dividend yield for each asset.

        See Also
        --------
        dividend_yield_annual : Calendar year dividend yield time series.
        dividends_annual : Calendar year dividends time series.
        dividend_paying_years : Number of years of consecutive dividend payments.
        dividend_growing_years : Number of years when the annual dividend was growing.
        get_dividend_mean_yield : Arithmetic mean for annual dividend yield.
        get_dividend_mean_growth_rate : Geometric mean of annual dividends growth rate.

        Examples
        --------
        >>> x = ok.AssetList(['T.US', 'XOM.US'], first_date='1984-01', last_date='1994-12')
        >>> x.dividend_yield
                   T.US    XOM.US
        1984-01  0.000000  0.000000
        1984-02  0.000000  0.002597
        1984-03  0.002038  0.002589
        1984-04  0.001961  0.002346
                   ...       ...
        1994-09  0.018165  0.012522
        1994-10  0.018651  0.011451
        1994-11  0.018876  0.012050
        1994-12  0.019344  0.011975
        [132 rows x 2 columns]
        """
        return super()._assets_dividend_yield

    @property
    def real_mean_return(self) -> float:
        """
        Calculate annualized real mean return (arithmetic mean) for the rate of return time series.

        Real rate of return is adjusted for inflation. Real return is defined if
        there is an `inflation=True` option in Portfolio.

        Returns
        -------
        float
            Annualized value of the mean for the real rate of return time series.

        Examples
        --------
        >>> pf = ok.Portfolio(['MSFT.US', 'AAPL.US'])
        >>> pf.real_mean_return
        0.3088967455111862
        """
        if not hasattr(self, "inflation"):
            raise ValueError("Real Return is not defined. Set inflation=True to calculate.")
        infl_mean = helpers.Float.annualize_return(self.inflation_ts.mean())
        ror_mean = helpers.Float.annualize_return(self.ror.mean())
        return (1.0 + ror_mean) / (1.0 + infl_mean) - 1.0

    @property
    def risk_monthly(self) -> pd.Series:
        """
        Calculate monthly risk expanding time series for Portfolio.

        Monthly risk of portfolio is a standard deviation of the rate of return time series.
        Standard deviation (sigma) is normalized by N-1.

        Returns
        -------
        Series
            Standard deviation of the monthly return expanding time series.

        See Also
        --------
        risk_annual : Calculate annualized risks.
        semideviation_monthly : Calculate semideviation monthly values.
        semideviation_annual : Calculate semideviation annualized values.
        get_var_historic : Calculate historic Value at Risk (VaR).
        get_cvar_historic : Calculate historic Conditional Value at Risk (CVaR).
        drawdowns : Calculate drawdowns.

        Examples
        --------
        >>> pf = ok.Portfolio(['MSFT.US', 'AAPL.US'])
        >>> pf.risk_monthly
        date
        1986-05    0.020117
        1986-06    0.122032
        1986-07    0.130113
        1986-08    0.116642
                     ...
        2023-08    0.092875
        2023-09    0.092861
        2023-10    0.092759
        2023-11    0.092763
        2023-12    0.092665
        Freq: M, Name: portfolio_1094.PF, Length: 453, dtype: float64
        """
        return self.ror.expanding().std().iloc[1:]

    @property
    def risk_annual(self) -> pd.Series:
        """
        Calculate annualized risk expanding time series for portfolio.

        Risk is a standard deviation of the rate of return.

        Annualized risk is calculated for rate of retirun time series for the sample from 'first_date' to
        'last_date'.

        Returns
        -------
        Series
            Annualized standard deviation of the monthly return expanding time series.

        Examples
        --------
        >>> pf = ok.Portfolio(['MSFT.US', 'AAPL.US'])
        >>> pf.risk_annual
        date
        1986-05    0.285175
        1986-06    0.890909
        1986-07    0.616876
        1986-08    0.632270
        1986-09    0.509642
                     ...
        2023-08    0.428297
        2023-09    0.427350
        2023-10    0.426961
        2023-11    0.427930
        """
        risk_ts = self.ror.expanding().std()
        mean_return_ts = self.ror.expanding().mean()
        return helpers.Float.annualize_risk(risk_ts, mean_return_ts).iloc[1:]

    @property
    def semideviation_monthly(self) -> float:
        """
        Calculate semi-deviation monthly value for portfolio rate of return time series.

        Semi-deviation (Downside risk) is the risk of the return being below the expected return.

        Returns
        -------
        float
            Semi-deviation monthly value for portfolio rate of return time series.

        Examples
        --------
        >>> pf = ok.Portfolio(['MSFT.US', 'AAPL.US'])
        >>> pf.semideviation_monthly
        0.05601433676604449
        """
        return helpers.Frame.get_semideviation(self.ror)

    @property
    def semideviation_annual(self) -> float:
        """
        Return semideviation annualized value for portfolio rate of return time series.

        Semi-deviation (Downside risk) is the risk of the return being below the expected return.

        Returns
        -------
        float
            Annualized semi-deviation monthly value for portfolio rate of return time series.

        Examples
        --------
        >>> pf = ok.Portfolio(['MSFT.US', 'AAPL.US'])
        >>> pf.semideviation_annual
        0.1940393544621248
        """
        return helpers.Frame.get_semideviation(self.ror) * 12**0.5

    def get_var_historic(self, time_frame: int = 12, level=1) -> float:
        """
        Calculate historic Value at Risk (VaR) for the portfolio.

        The VaR calculates the potential loss of an investment with a given time frame and confidence level.
        Loss is a positive number (expressed in cumulative return).
        If VaR is negative there are expected gains at this confidence level.

        Parameters
        ----------
        time_frame : int, default 12
            Time frame for VAR. Default is 12 months.
        level : int, default 1
            Confidence level in percents. Default value is 1%.

        Returns
        -------
        float
            Historic Value at Risk (VaR) value for the portfolio.

        Examples
        --------
        >>> x = ok.Portfolio(['SP500TR.INDX', 'SP500BDT.INDX'], last_date='2021-01')
        >>> x.get_var_historic(time_frame=12, level=1)
        0.24030006476701732
        """
        # remove inflation column from rolling return
        df = self.get_rolling_cumulative_return(window=time_frame).loc[:, [self.symbol]]
        return helpers.Frame.get_var_historic(df, level).iloc[0]

    def get_cvar_historic(self, time_frame: int = 12, level=1) -> float:
        """
        Calculate historic Conditional Value at Risk (CVAR, expected shortfall) for the portfolio.

        CVaR is the average loss over a specified time period of unlikely scenarios beyond the confidence level.
        Loss is a positive number (expressed in cumulative return).
        If CVaR is negative there are expected gains at this confidence level.

        Parameters
        ----------
        time_frame : int, default 12
            Time period size in months.
        level : int, default 1
            Confidence level in percents to calculate the VaR. Default value is 1% (1% quantile).

        Returns
        -------
        float
            Historic Conditional Value at Risk (CVAR, expected shortfall) value for the portfolio.

        Examples
        --------
        >>> x = ok.Portfolio(['USDEUR.FX', 'BTC-USD.CC'], last_date='2021-01')
        >>> x.get_cvar_historic(time_frame=2, level=1)
        0.3566909250442616
        """
        # remove inflation column form rolling return
        df = self.get_rolling_cumulative_return(window=time_frame).loc[:, [self.symbol]]
        return helpers.Frame.get_cvar_historic(df, level).iloc[0]

    @property
    def drawdowns(self) -> pd.Series:
        """
        Calculate drawdowns time series for the portfolio.

        The drawdown is the percent decline from a previous peak in wealth index.

        Returns
        -------
        Series
            Drawdowns time series for the portfolio
        """
        return helpers.Frame.get_drawdowns(self.ror)

    @property
    def recovery_period(self) -> pd.Series:
        """
        Get recovery period time series for the portfolio value.

        The recovery period (drawdown duration) is the number of months to reach the value of the last maximum.

        Returns
        -------
        pd.Series
            Recovery period time series for the portfolio value

        Notes
        -----
        The largest recovery period does not necessary correspond to the max drawdown.

        Examples
        --------
        >>> pf = ok.Portfolio(['SPY.US', 'AGG.US'], weights=[0.5, 0.5])
        >>> pf.recovery_period.nlargest()
        date
        2010-10    35
        2004-10     7
        2012-01     7
        2019-03     6
        2018-07     5
        Freq: M, Name: portfolio_5724.PF, dtype: int32

        See Also
        --------
        drawdowns : Calculate drawdowns time series.
        """
        w_index = self.wealth_index_with_assets[self.symbol]
        cummax = w_index.cummax()
        s = cummax.pct_change()[1:]
        s1 = s.where(s == 0).notnull().astype(int)
        s1_1 = s.where(s == 0).isnull().astype(int).cumsum()
        s2 = s1.groupby(s1_1).cumsum()
        return s2[s2.shift(-1) < s2]

    def describe(self, years: Tuple[int] = (1, 5, 10)) -> pd.DataFrame:
        """
        Generate descriptive statistics for the portfolio.

        Statistics includes:

        - YTD (Year To date) compound return
        - CAGR for a given list of periods and full available period
        - Annualized mean rate of return (full available period)
        - LTM Dividend yield - last twelve months dividend yield

        Risk metrics (full available period):

        - risk (standard deviation)
        - CVAR (timeframe is 1 year)
        - max drawdowns (and dates)

        Parameters
        ----------
        years : tuple of int, default (1, 5, 10)
            List of periods for CAGR statistics.

        Returns
        -------
        DataFrame
            Table of descriptive statistics for the portfolio.

        See Also
        --------
        get_cumulative_return : Calculate cumulative return.
        get_cagr : Calculate portfolio Compound Annual Growth Rate (CAGR).
        dividend_yield : Calculate dividend yield (LTM).
        risk_annual : Return annualized risks (standard deviation).
        get_cvar_historic : Calculate historic Conditional Value at Risk (CVAR, expected shortfall).
        drawdowns : Calculate drawdowns.

        Examples
        --------
        >>> pf = ok.Portfolio(['SPY.US', 'BND.US'], ccy='USD', last_date='07-2021')
        >>> pf.describe(years=[2, 5, 7])  # 'years' customizes the timeframe for the CAGR
                    property              period portfolio_2951.PF  inflation
        0    compound return                 YTD          0.084098   0.048154
        1               CAGR             2 years          0.141465   0.031566
        2               CAGR             5 years          0.102494   0.025582
        3               CAGR             7 years          0.091694   0.019656
        4               CAGR  14 years, 3 months          0.074305   0.019724
        5     Dividend yield                 LTM          0.016504        NaN
        6               Risk  14 years, 3 months          0.086103        NaN
        7               CVAR  14 years, 3 months          0.214207        NaN
        8       Max drawdown  14 years, 3 months         -0.266915        NaN
        9  Max drawdown date  14 years, 3 months           2009-02        NaN
        """
        rows_list = []
        dt0 = self.last_date
        df = self._add_inflation()
        # YTD return
        ytd_return = self.get_cumulative_return(period="YTD")
        row = ytd_return.to_dict()
        row.update(period="YTD", property="compound return")
        rows_list.append(row)
        # CAGR for a list of periods
        if self.pl.years >= 1:
            for i in years:
                dt = helpers.Date.subtract_years(dt0, i)
                if dt >= self.first_date:
                    row = self.get_cagr(period=i).to_dict()
                else:
                    row = {x: None for x in df.columns} if hasattr(self, "inflation") else {self.symbol: None}
                row.update(period=f"{i} years", property="CAGR")
                rows_list.append(row)
            # CAGR for full period
            row = self.get_cagr(period=None).to_dict()
            row.update(
                period=self._pl_txt,
                property="CAGR",
            )
            rows_list.append(row)
            # Mean rate of return (arithmetic mean)
            value = self.mean_return_annual
            row = {self.symbol: value}
            row.update(
                period=self._pl_txt,
                property="Annualized mean return",
            )
            rows_list.append(row)
            # Dividend Yield
            value = self.dividend_yield.iloc[-1]
            row = {self.symbol: value}
            row.update(
                period="LTM",
                property="Dividend yield",
            )
            rows_list.append(row)
        # risk (standard deviation)
        row = {self.symbol: self.risk_annual.iloc[-1]}
        row.update(period=self._pl_txt, property="Risk")
        rows_list.append(row)
        # CVAR
        if self.pl.years >= 1:
            row = {self.symbol: self.get_cvar_historic(level=1)}
            row.update(
                period=self._pl_txt,
                property="CVAR (α=1)",
            )
            rows_list.append(row)
        # max drawdowns
        row = {self.symbol: self.drawdowns.min()}
        row.update(
            period=self._pl_txt,
            property="Max drawdown",
        )
        rows_list.append(row)
        # max drawdowns dates
        row = {self.symbol: self.drawdowns.idxmin()}
        row.update(
            period=self._pl_txt,
            property="Max drawdown date",
        )
        rows_list.append(row)
        # Create DataFrame from list of rows
        description = pd.DataFrame(rows_list)
        if hasattr(self, "inflation"):
            description.rename(columns={self.inflation: "inflation"}, inplace=True)
        description = helpers.Frame.change_columns_order(description, ["property", "period", self.symbol])
        return description

    @property
    def table(self) -> pd.DataFrame:
        """
        Return table with security name, ticker, weight for assets in the portfolio.

        Returns
        -------
        DataFrame
            Security name - ticker - weight table.

        Examples
        --------
        >>> pf = ok.Portfolio(["MSFT.US", "AAPL.US"])
        >>> pf.table
                        asset name   ticker  weights
        0  Microsoft Corporation  MSFT.US      0.5
        1              Apple Inc  AAPL.US      0.5
        """
        x = pd.DataFrame(
            data={
                "asset name": list(self.names.values()),
                "ticker": list(self.names.keys()),
            }
        )
        x["weights"] = self.weights
        return x

    # Historical percentiles
    def percentile_inverse_cagr(
        self,
        years: int = 1,
        score: float = 0,
    ) -> float:
        """
        Compute the percentile rank of a score (CAGR value).

        Percentile rank can be calculated for a historical distribution of CAGR.

        If percentile_inverse of, for example, 0% (CAGR value) is equal to 8% for 1 year time frame
        it means that 8% of the CAGR values in the distribution are negative in 1 year periods. Or in other words
        the probability of getting negative result after 1 year of investments is 8%.

        Parameters
        ----------
        years : int, default 1
            Period length (time frame) in years when CAGR is calculated.
        score : float, default 0
            Score that is compared to the elements in CAGR array.

        Returns
        -------
        float
            Percentile-position of score (0-100) relative to distribution.

        Examples
        --------
        >>> pf = ok.Portfolio(
        ...     ['SPY.US', 'AGG.US', 'GLD.US'],
        ...     weights=[.60, .35, .05],
        ...     rebalancing_strategy=ok.Rebalance(period="year"),
        ... )
        >>> pf.percentile_inverse_cagr(score=0, years=1)
        18.08
        The probability of getting negative result (score=0) in 1 year period for historical distribution.
        """
        cagr_distr = self.get_rolling_cagr(years * settings._MONTHS_PER_YEAR).loc[:, [self.symbol]].squeeze()
        return scipy.stats.percentileofscore(cagr_distr, score, kind="rank")

    def percentile_cagr(self, years: int, percentiles: List[int] = [10, 50, 90]) -> pd.DataFrame:
        """
        Calculate given percentiles for portfolio rolling CAGR distribution from the historical data.

        CAGR - Compound Annual Growth Rate.
        Each percentile is calculated for a period range from 1 year to 'years'.

        Parameters
        ----------
        years : int
            Max window size for rolling CAGR in the distribution in years.
            It should not exceed 1/2 of the portfolio history period length 'period_length'.
        percentiles : list of int, default [10, 50, 90]
            List of percentiles to be calculated.

        Returns
        -------
        DataFrame
            Table with percentiles values for each period from 1 to 'years'.

        Examples
        --------
        >>> pf = ok.Portfolio(
        ...     ['SPY.US', 'AGG.US', 'GLD.US'],
        ...     weights=[.60, .35, .05],
        ...     rebalancing_strategy=ok.Rebalance(period="none"),
        ... )
        >>> pf.percentile_cagr(years=5, percentiles=[1, 50, 99])
                     1         50        99
        years
        1     -0.231327  0.098693  0.295343
        2     -0.101689  0.091824  0.206471
        3     -0.036771  0.085428  0.157833
        4     -0.007674  0.085178  0.142195
        5      0.030933  0.082865  0.134496
        """
        period_range = range(1, years + 1)
        returns_dict = {}
        for percentile in percentiles:
            percentile_returns_list = [
                self.get_rolling_cagr(years * 12).loc[:, self.symbol].quantile(percentile / 100)
                for years in period_range
            ]
            returns_dict.update({percentile: percentile_returns_list})
        df = pd.DataFrame(returns_dict, index=list(period_range))
        df.index.rename("years", inplace=True)
        return df

    def get_sharpe_ratio(self, rf_return: float = 0) -> float:
        """
        Calculate Sharpe ratio.

        The Sharpe ratio is the average annual return in excess of the risk-free rate
        per unit of risk (annualized standard deviation).

        Risk-free rate should be taken according to the Portfolio base currency.

        Parameters
        ----------
        rf_return : float, default 0
            Risk-free rate of return.

        Returns
        -------
        float

        Examples
        --------
        >>> pf = ok.Portfolio(['VOO.US', 'BND.US'], weights=[0.40, 0.60])
        >>> pf.get_sharpe_ratio(rf_return=0.04)
        0.7412193684695373
        """
        return ratios.get_sharpe_ratio(
            pf_return=self.mean_return_annual,
            rf_return=rf_return,
            std_deviation=self.risk_annual.iloc[-1],
        )

    def get_sortino_ratio(self, t_return: float = 0) -> float:
        """
        Calculate Sortino ratio for the portfolio with specified target return.

        Sortion ratio measures the risk-adjusted return of portfolio. It is a modification of the Sharpe ratio
        but penalizes only those returns falling below a specified target rate of return, while
        the Sharpe ratio penalizes both upside and downside volatility equally.

        Parameters
        ----------
        t_return : float, default 0
            Traget rate of return.

        Returns
        -------
        float

        Examples
        --------
        >>> pf = ok.Portfolio(['VOO.US', 'BND.US'], last_date='2021-12')
        >>> pf.get_sortino_ratio(t_return=0.02)
        1.4377728903230174
        """
        semideviation = helpers.Frame.get_below_target_semideviation(ror=self.ror, t_return=t_return) * 12**0.5
        return ratios.get_sortino_ratio(
            pf_return=self.mean_return_annual,
            t_return=t_return,
            semi_deviation=semideviation,
        )

    @property
    def diversification_ratio(self) -> float:
        """
        Calculate Diversification Ratio for the portfolio.

        The Diversification Ratio is the ratio of the weighted average of assets risks divided by the portfolio risk.
        In this case risk is the annuilized standatd deviation for the rate of return .

        Returns
        -------
        float

        Examples
        --------
        >>> pf = ok.Portfolio(['VOO.US', 'BND.US'], weights=[0.7, 0.3], last_date='2021-12')
        >>> pf.diversification_ratio
        1.1264305597257505
        """
        assets_risk = self.assets_ror.std()
        assets_mean_return = self.assets_ror.mean()
        assets_annualized_risk = helpers.Float.annualize_risk(assets_risk, assets_mean_return)
        weights = np.asarray(self.weights)
        sigma_weighted_sum = weights.T @ assets_annualized_risk
        return sigma_weighted_sum / self.risk_annual.iloc[-1]

    @property
    def okamaio_link(self) -> str:
        """
        URL link to portfolio at okama.io.

        Portfolio with the same tickers, weights and other properties at okama.io financial widgets.

        Returns
        -------
        str
            URL link to portfolio at okama.io.

        Examples
        --------
        >>> pf = ok.Portfolio(
        ...     ['SPY.US', 'AGG.US'],
        ...     weights=[.60, .40],
        ...     rebalancing_strategy=ok.Rebalance(period="year", abs_deviation=0.05),
        ... )
        >>> pf.okamaio_link
        'https://okama.io/portfolio?tickers=SPY.US,AGG.US&weights=60.0,40.0&ccy=USD&first_date=2003-10-01&last_date=2024-08-01&rebal=year&rebalancing_period=year&rebalancing_abs_deviation=0.05&symbol=portfolio_6323.PF'
        """
        weights_percent = [w * 100 for w in self.weights]
        query_params = {
            "tickers": ",".join(str(symbol) for symbol in self.symbols),
            "weights": ",".join(str(weight) for weight in weights_percent),
            "ccy": self.currency,
            "first_date": self.first_date.strftime("%Y-%m-%d"),
            "last_date": self.last_date.strftime("%Y-%m-%d"),
            "rebal": self.rebalancing_strategy.period,
            "rebalancing_period": self.rebalancing_strategy.period,
            "symbol": self.symbol,
        }
        if self.rebalancing_strategy.abs_deviation is not None:
            query_params["rebalancing_abs_deviation"] = self.rebalancing_strategy.abs_deviation
        if self.rebalancing_strategy.rel_deviation is not None:
            query_params["rebalancing_rel_deviation"] = self.rebalancing_strategy.rel_deviation
        return f"https://okama.io/portfolio?{urlencode(query_params, safe=',')}"

    def _clear_cf_cache(self):
        self._monte_carlo_wealth = pd.DataFrame()
        self._wealth_index = pd.DataFrame()
        self._cash_flow_fv = pd.DataFrame()
