"""The module facilitates a class `EfficientFrontier` that can be used to
optimise a portfolio by minimising a cost/objective function.
"""


import numpy as np
import pandas as pd
import scipy.optimize as sco
import matplotlib.pylab as plt
import finquant.minimise_fun as min_fun
from finquant.quants import annualised_portfolio_quantities


class EfficientFrontier(object):
    """An object designed to perform optimisations based on minimising a cost/objective function.
    It can find parameters for portfolios with

    - minimum volatility
    - maximum Sharpe ratio
    - minimum volatility for a given target return
    - maximum Sharpe ratio for a given target volatility

    It also provides functions to compute the Efficient Frontier between a range
    of Returns, plot the Efficient Frontier, plot the optimal portfolios
    (minimum Volatility and maximum Sharpe Ratio).
    """

    def __init__(
        self, mean_returns, cov_matrix, risk_free_rate=0.005, freq=252, method="SLSQP"
    ):
        """
        :Input:
         :mean_returns: ``pandas.Series``, individual expected returns for all
             stocks in the portfolio
         :cov_matrix: ``pandas.DataFrame``, covariance matrix of returns
         :risk_free_rate: ``int``/``float`` (default= ``0.005``), risk free rate
         :freq: ``int`` (default= ``252``), number of trading days, default
             value corresponds to trading days in a year
         :method: ``string`` (default= ``"SLSQP"``), type of solver method to use,
             must be one of:

             - 'Nelder-Mead'
             - 'Powell'
             - 'CG'
             - 'BFGS'
             - 'Newton-CG'
             - 'L-BFGS-B'
             - 'TNC'
             - 'COBYLA'
             - 'SLSQP'
             - 'trust-constr'
             - 'dogleg'
             - 'trust-ncg'
             - 'trust-exact'
             - 'trust-krylov'

             all of which are officially supported by scipy.optimize.minimize
        """
        if not isinstance(mean_returns, pd.Series):
            raise ValueError("mean_returns is expected to be a pandas.Series.")
        if not isinstance(cov_matrix, pd.DataFrame):
            raise ValueError("cov_matrix is expected to be a pandas.DataFrame")
        supported_methods = [
            "Nelder-Mead",
            "Powell",
            "CG",
            "BFGS",
            "Newton-CG",
            "L-BFGS-B",
            "TNC",
            "COBYLA",
            "SLSQP",
            "trust-constr",
            "dogleg",
            "trust-ncg",
            "trust-exact",
            "trust-krylov",
        ]
        if not isinstance(risk_free_rate, (int, float)):
            raise ValueError("risk_free_rate is expected to be an integer or float.")
        if not isinstance(method, str):
            raise ValueError("method is expected to be a string.")
        if method not in supported_methods:
            raise ValueError("method is not supported by scipy.optimize.minimize.")

        # instance variables
        self.mean_returns = mean_returns
        self.cov_matrix = cov_matrix
        self.risk_free_rate = risk_free_rate
        self.freq = freq
        self.method = method
        self.names = list(mean_returns.index)
        self.num_stocks = len(self.names)
        self.last_optimisation = ""

        # set numerical parameters
        bound = (0, 1)
        self.bounds = tuple(bound for stock in range(self.num_stocks))
        self.x0 = np.array(self.num_stocks * [1.0 / self.num_stocks])
        self.constraints = {"type": "eq", "fun": lambda x: np.sum(x) - 1}

        # placeholder for optimised values/weights
        self.weights = None
        self.df_weights = None
        self.efrontier = None

    def minimum_volatility(self, save_weights=True):
        """Finds the portfolio with the minimum volatility.

        :Input:
         :save_weights: ``boolean`` (default= ``True``), for internal use only.
             Whether to save the optimised weights in the instance variable
             ``weights`` (and ``df_weights``). Useful for the case of computing
             the efficient frontier after doing an optimisation, else the optimal
             weights would be overwritten by the efficient frontier computations.
             Best to ignore this argument.

        :Output:
         :df_weights:
           - if "save_weights" is True: a ``pandas.DataFrame`` of weights/allocation
             of stocks within the optimised portfolio.
         :weights:
           - if "save_weights" is False: a ``numpy.ndarray`` of weights/allocation
             of stocks within the optimised portfolio.
        """
        if not isinstance(save_weights, bool):
            raise ValueError("save_weights is expected to be a boolean.")
        args = (self.mean_returns.values, self.cov_matrix.values)
        # optimisation
        result = sco.minimize(
            min_fun.portfolio_volatility,
            args=args,
            x0=self.x0,
            method=self.method,
            bounds=self.bounds,
            constraints=self.constraints,
        )
        # if successful, set self.last_optimisation
        self.last_optimisation = "Minimum Volatility"
        # set optimal weights
        if save_weights:
            self.weights = result["x"]
            self.df_weights = self._dataframe_weights(self.weights)
            return self.df_weights
        else:
            # not setting instance variables, and returning array instead
            # of pandas.DataFrame
            return result["x"]

    def maximum_sharpe_ratio(self, save_weights=True):
        """Finds the portfolio with the maximum Sharpe Ratio, also called the
        tangency portfolio.

        :Input:
         :save_weights: ``boolean`` (default= ``True``), for internal use only.
             Whether to save the optimised weights in the instance variable
             ``weights`` (and ``df_weights``). Useful for the case of computing
             the efficient frontier after doing an optimisation, else the optimal
             weights would be overwritten by the efficient frontier computations.
             Best to ignore this argument.

        :Output:
         :df_weights:
           - if "save_weights" is True: a ``pandas.DataFrame`` of weights/allocation
             of stocks within the optimised portfolio.
         :weights:
           - if "save_weights" is False: a ``numpy.ndarray`` of weights/allocation
             of stocks within the optimised portfolio.
        """
        if not isinstance(save_weights, bool):
            raise ValueError("save_weights is expected to be a boolean.")
        args = (self.mean_returns.values, self.cov_matrix.values, self.risk_free_rate)
        # optimisation
        result = sco.minimize(
            min_fun.negative_sharpe_ratio,
            args=args,
            x0=self.x0,
            method=self.method,
            bounds=self.bounds,
            constraints=self.constraints,
        )
        # if successful, set self.last_optimisation
        self.last_optimisation = "Maximum Sharpe Ratio"
        # set optimal weights
        if save_weights:
            self.weights = result["x"]
            self.df_weights = self._dataframe_weights(self.weights)
            return self.df_weights
        else:
            # not setting instance variables, and returning array instead
            # of pandas.DataFrame
            return result["x"]

    def efficient_return(self, target, save_weights=True):
        """Finds the portfolio with the minimum volatility for a given target
        return.

        :Input:
         :target: ``float``, the target return of the optimised portfolio.
         :save_weights: ``boolean`` (default= ``True``), for internal use only.
             Whether to save the optimised weights in the instance variable
             ``weights`` (and ``df_weights``). Useful for the case of computing
             the efficient frontier after doing an optimisation, else the optimal
             weights would be overwritten by the efficient frontier computations.
             Best to ignore this argument.

        :Output:
         :df_weights:
           - if "save_weights" is True: a ``pandas.DataFrame`` of weights/allocation
             of stocks within the optimised portfolio.
         :weights:
           - if "save_weights" is False: a ``numpy.ndarray`` of weights/allocation
             of stocks within the optimised portfolio.
        """
        if not isinstance(target, (int, float)):
            raise ValueError("target is expected to be an integer or float.")
        if not isinstance(save_weights, bool):
            raise ValueError("save_weights is expected to be a boolean.")
        args = (self.mean_returns.values, self.cov_matrix.values)
        # here we have an additional constraint:
        constraints = (
            self.constraints,
            {
                "type": "eq",
                "fun": lambda x: min_fun.portfolio_return(
                    x, self.mean_returns, self.cov_matrix
                )
                - target,
            },
        )
        # optimisation
        result = sco.minimize(
            min_fun.portfolio_volatility,
            args=args,
            x0=self.x0,
            method=self.method,
            bounds=self.bounds,
            constraints=constraints,
        )
        # if successful, set self.last_optimisation
        self.last_optimisation = "Efficient Return"
        # set optimal weights
        if save_weights:
            self.weights = result["x"]
            self.df_weights = self._dataframe_weights(self.weights)
            return self.df_weights
        else:
            # not setting instance variables, and returning array instead
            # of pandas.DataFrame
            return result["x"]

    def efficient_volatility(self, target):
        """Finds the portfolio with the maximum Sharpe ratio for a given
        target volatility.

        :Input:
         :target: ``float``, the target volatility of the optimised portfolio.

        :Output:
         :df_weights: a ``pandas.DataFrame`` of weights/allocation of stocks within
             the optimised portfolio.
        """
        if not isinstance(target, (int, float)):
            raise ValueError("target is expected to be an integer or float.")
        args = (self.mean_returns.values, self.cov_matrix.values, self.risk_free_rate)
        # here we have an additional constraint:
        constraints = (
            self.constraints,
            {
                "type": "eq",
                "fun": lambda x: min_fun.portfolio_volatility(
                    x, self.mean_returns, self.cov_matrix
                )
                - target,
            },
        )
        # optimisation
        result = sco.minimize(
            min_fun.negative_sharpe_ratio,
            args=args,
            x0=self.x0,
            method=self.method,
            bounds=self.bounds,
            constraints=constraints,
        )
        # if successful, set self.last_optimisation
        self.last_optimisation = "Efficient Volatility"
        # set optimal weights
        self.weights = result["x"]
        self.df_weights = self._dataframe_weights(self.weights)
        return self.df_weights

    def efficient_frontier(self, targets=None):
        """Gets portfolios for a range of given target returns.
        If no targets were provided, the algorithm will find the minimum
        and maximum returns of the portfolio's individual stocks, and set
        the target range according to those values.
        Results in the Efficient Frontier.

        :Input:
         :targets: ``list``/``numpy.ndarray`` (default= ``None``) of ``floats``,
             range of target returns.

        :Output:
         :efrontier: ``numpy.ndarray`` of (volatility, return) values
        """
        if targets is not None and not isinstance(targets, (list, np.ndarray)):
            raise ValueError("targets is expected to be a list or numpy.ndarray")
        elif targets is None:
            # set range of target returns from the individual expected
            # returns of the stocks in the portfolio.
            min_return = self.mean_returns.min() * self.freq
            max_return = self.mean_returns.max() * self.freq
            targets = np.linspace(round(min_return, 3), round(max_return, 3), 100)
        # compute the efficient frontier
        efrontier = []
        for target in targets:
            weights = self.efficient_return(target, save_weights=False)
            efrontier.append(
                [
                    annualised_portfolio_quantities(
                        weights, self.mean_returns, self.cov_matrix, freq=self.freq
                    )[1],
                    target,
                ]
            )
        self.efrontier = np.array(efrontier)
        return self.efrontier

    def plot_efrontier(self):
        """Plots the Efficient Frontier."""
        if self.efrontier is None:
            # compute efficient frontier first
            self.efficient_frontier()
        plt.plot(
            self.efrontier[:, 0],
            self.efrontier[:, 1],
            linestyle="-.",
            color="black",
            lw=2,
            label="Efficient Frontier",
        )
        plt.title("Efficient Frontier")
        plt.xlabel("Volatility")
        plt.ylabel("Expected Return")
        plt.legend()

    def plot_optimal_portfolios(self):
        """Plots markers of the optimised portfolios for

         - minimum Volatility, and
         - maximum Sharpe Ratio.
        """
        # compute optimal portfolios
        min_vol_weights = self.minimum_volatility(save_weights=False)
        max_sharpe_weights = self.maximum_sharpe_ratio(save_weights=False)
        # compute return and volatility for each portfolio
        min_vol_vals = list(
            annualised_portfolio_quantities(
                min_vol_weights, self.mean_returns, self.cov_matrix, freq=self.freq
            )
        )[0:2]
        min_vol_vals.reverse()
        max_sharpe_vals = list(
            annualised_portfolio_quantities(
                max_sharpe_weights, self.mean_returns, self.cov_matrix, freq=self.freq
            )
        )[0:2]
        max_sharpe_vals.reverse()
        plt.scatter(
            min_vol_vals[0],
            min_vol_vals[1],
            marker="X",
            color="g",
            s=150,
            label="EF min Volatility",
        )
        plt.scatter(
            max_sharpe_vals[0],
            max_sharpe_vals[1],
            marker="X",
            color="r",
            s=150,
            label="EF max Sharpe Ratio",
        )
        plt.legend()

    def _dataframe_weights(self, weights):
        """Generates and returns a ``pandas.DataFrame`` from given
        array weights.

        :Input:
         :weights: ``numpy.ndarray``, weights of the stock of the portfolio

        :Output:
         :weights: ``pandas.DataFrame`` with the weights/allocation of stocks
        """
        if not isinstance(weights, np.ndarray):
            raise ValueError("weights is expected to be a numpy.ndarray")
        return pd.DataFrame(weights, index=self.names, columns=["Allocation"])

    def properties(self, verbose=False):
        """Calculates and prints out Expected annualised Return,
        Volatility and Sharpe Ratio of optimised portfolio.

        :Input:
         :verbose: ``boolean`` (default= ``False``), whether to print out properties or not
        """
        if not isinstance(verbose, bool):
            raise ValueError("verbose is expected to be a boolean.")
        if self.weights is None:
            raise ValueError("Perform an optimisation first.")
        expected_return, volatility, sharpe = annualised_portfolio_quantities(
            self.weights,
            self.mean_returns,
            self.cov_matrix,
            risk_free_rate=self.risk_free_rate,
            freq=self.freq,
        )
        if verbose:
            string = "-" * 70
            string += "\nOptimised portfolio for {}".format(self.last_optimisation)
            string += "\n\nTime window/frequency: {}".format(self.freq)
            string += "\nRisk free rate: {}".format(self.risk_free_rate)
            string += "\nExpected annual Return: {:.3f}".format(expected_return)
            string += "\nAnnual Volatility: {:.3f}".format(volatility)
            string += "\nSharpe Ratio: {:.3f}".format(sharpe)
            string += "\n\nOptimal weights:"
            string += "\n" + str(self.df_weights.transpose())
            string += "\n"
            string += "-" * 70
            print(string)
        return (expected_return, volatility, sharpe)