From 2a998a5a417ba81b6ee3c4de90d2ffaca52b46fa Mon Sep 17 00:00:00 2001 From: jmaslek Date: Wed, 2 Feb 2022 15:20:05 -0500 Subject: [PATCH] Portfolio class (#1280) * remerge into main ? * tests again * change the example csv * squash all the bugs * Improve `add` interface * Add warning on loading portfolio with no cash * left a rogue print * oopsie. hugo * oopsie. hugo * Add back a new `al` function + port name * test Co-authored-by: Colin Delahunty <72827203+colin99d@users.noreply.github.com> --- gamestonk_terminal/portfolio/portfolio_api.py | 1 - .../portfolio/portfolio_controller.py | 458 +++++----- .../portfolio/portfolio_model.py | 797 ++++++++---------- .../portfolio/portfolio_view.py | 288 +++++-- .../portfolio/portfolios/bitcoin.csv | 3 + .../portfolio/portfolios/example.csv | 15 +- .../portfolio/portfolios/james.csv | 5 - .../portfolio/yfinance_model.py | 13 +- .../portfolio/test_portfolio_controller.py | 2 +- .../test_print_help.txt | 11 +- website/content/portfolio/al/_index.md | 14 +- website/content/portfolio/build/_index.md | 33 + .../content/portfolio/{mdd => dd}/_index.md | 0 website/content/portfolio/init/_index.md | 9 + website/content/portfolio/rmv/_index.md | 10 - website/content/portfolio/rolling/_index.md | 20 + website/data/menu/main.yml | 10 +- 17 files changed, 922 insertions(+), 767 deletions(-) create mode 100644 gamestonk_terminal/portfolio/portfolios/bitcoin.csv delete mode 100644 gamestonk_terminal/portfolio/portfolios/james.csv create mode 100644 website/content/portfolio/build/_index.md rename website/content/portfolio/{mdd => dd}/_index.md (100%) create mode 100644 website/content/portfolio/init/_index.md delete mode 100644 website/content/portfolio/rmv/_index.md create mode 100644 website/content/portfolio/rolling/_index.md diff --git a/gamestonk_terminal/portfolio/portfolio_api.py b/gamestonk_terminal/portfolio/portfolio_api.py index 261b429f24f..c79604d79c4 100644 --- a/gamestonk_terminal/portfolio/portfolio_api.py +++ b/gamestonk_terminal/portfolio/portfolio_api.py @@ -9,7 +9,6 @@ from gamestonk_terminal.helper_classes import ModelsNamespace as _models from .brokers import bro_api as bro from .portfolio_analysis import pa_api as pa from .portfolio_optimization import po_api as po -from .portfolio_view import show_df as show from .portfolio_view import Report as ar from .portfolio_view import plot_overall_return as rmr diff --git a/gamestonk_terminal/portfolio/portfolio_controller.py b/gamestonk_terminal/portfolio/portfolio_controller.py index 16585f272e9..033412aa5ff 100644 --- a/gamestonk_terminal/portfolio/portfolio_controller.py +++ b/gamestonk_terminal/portfolio/portfolio_controller.py @@ -5,8 +5,7 @@ import argparse import os from os import listdir from os.path import isfile, join -from typing import List -from datetime import datetime +from typing import List, Dict, Union from prompt_toolkit.completion import NestedCompleter import pandas as pd @@ -18,6 +17,7 @@ from gamestonk_terminal.helper_funcs import ( check_positive_float, check_positive, EXPORT_ONLY_FIGURES_ALLOWED, + print_rich_table, ) from gamestonk_terminal.menu import session @@ -25,18 +25,30 @@ from gamestonk_terminal.portfolio.portfolio_optimization import po_controller from gamestonk_terminal.portfolio import ( portfolio_view, portfolio_model, - yfinance_model, - portfolio_helper, ) from gamestonk_terminal.helper_funcs import parse_known_args_and_warn # pylint: disable=R1710,E1101,C0415 +portfolios_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "portfolios") + class PortfolioController(BaseController): """Portfolio Controller class""" - CHOICES_COMMANDS = ["load", "save", "show", "add", "rmv", "ar", "rmr", "al", "mdd"] + CHOICES_COMMANDS = [ + "load", + "save", + "init", + "show", + "add", + "build", + "ar", + "rmr", + "al", + "dd", + "rolling", + ] CHOICES_MENUS = [ "bro", "po", @@ -61,13 +73,20 @@ class PortfolioController(BaseController): ] ) + self.portfolio_name = "" + self.portlist: List[str] = os.listdir(portfolios_path) + self.portfolio = portfolio_model.Portfolio() + if session and gtff.USE_PROMPT_TOOLKIT: choices: dict = {c: {} for c in self.controller_choices} + choices["load"] = {c: None for c in self.portlist} + choices["save"] = {c: None for c in self.portlist} + self.choices = choices self.completer = NestedCompleter.from_nested_dict(choices) def print_help(self): """Print help""" - help_text = """[menu] + help_text = f"""[menu] > bro brokers holdings, \t\t supports: robinhood, ally, degiro, coinbase > po portfolio optimization, \t optimal portfolio weights from pyportfolioopt > pa portfolio analysis, \t\t analyse portfolios[/menu] @@ -77,16 +96,21 @@ class PortfolioController(BaseController): save save your portfolio for future use show show existing transactions add add a security to your portfolio - rmv remove a security from your portfolio[/cmds] - -[info]Reports:[/info][cmds] - ar annual report for performance of a given portfolio + init initialize empty portfolio + build build portfolio from list of tickers and weights[/cmds] +[info] +Loaded:[/info] {self.portfolio_name or None} [info]Graphs:[/info][cmds] + rolling display rolling metrics of portfolio and benchmark rmr graph your returns versus the market's returns - al displays the allocation of the portfolio - mdd display portfolio drawdown[/cmds] + dd display portfolio drawdown + al display allocation to given assets over period[/cmds] """ + # TODO: Clean up the reports inputs + # TODO: Edit the allocation to allow the different asset classes + # [info]Reports:[/info] + # ar annual report for performance of a given portfolio console.print(text=help_text, menu="Portfolio") def call_bro(self, _): @@ -111,6 +135,19 @@ class PortfolioController(BaseController): pa_controller.PortfolioAnalysis, self.queue ) + def call_init(self, other_args: List[str]): + """Process reset command""" + parser = argparse.ArgumentParser( + add_help=False, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + prog="init", + description="Re-initialize with empty portfolio.", + ) + ns_parser = parse_known_args_and_warn(parser, other_args) + if ns_parser: + self.portfolio = portfolio_model.Portfolio() + console.print() + def call_load(self, other_args: List[str]): """Process load command""" path = os.path.dirname(os.path.abspath(__file__)) @@ -136,8 +173,11 @@ class PortfolioController(BaseController): ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: - self.portfolio = portfolio_model.load_df(ns_parser.name) - console.print("") + self.portfolio = portfolio_model.Portfolio.from_csv( + os.path.join(portfolios_path, ns_parser.name) + ) + self.portfolio_name = ns_parser.name + console.print() def call_save(self, other_args: List[str]): """Process save command""" @@ -165,7 +205,7 @@ class PortfolioController(BaseController): or ".xlsx" in ns_parser.name or ".json" in ns_parser.name ): - portfolio_model.save_df(self.portfolio, ns_parser.name) + portfolio_model.save_df(self.portfolio.trades, ns_parser.name) else: console.print( "Please submit as 'filename.filetype' with filetype being csv, xlsx, or json\n" @@ -173,7 +213,11 @@ class PortfolioController(BaseController): def call_show(self, _): """Process show command""" - portfolio_view.show_df(self.portfolio, False) + if self.portfolio.empty: + console.print("[red]No portfolio loaded.[/red]\n") + return + print_rich_table(self.portfolio.trades, show_index=False) + console.print() def call_add(self, other_args: List[str]): """Process add command""" @@ -183,140 +227,169 @@ class PortfolioController(BaseController): prog="add", description="Adds an item to your portfolio", ) - parser.add_argument( - "-n", - "--name", - type=str, - dest="name", - required="-h" not in other_args, - help="Name of item to be added (for example a ticker for a stock)", - ) - parser.add_argument( - "-t", - "--type", - dest="type", - type=lambda s: s.lower(), - choices=["stock", "cash", "etf"], # "bond", "option", "crypto", - default="stock", - help="Type of asset to add", - ) - parser.add_argument( - "-q", - "--quantity", - dest="quantity", - type=check_positive_float, - default=1, - help="Amounts of the asset owned", - ) - parser.add_argument( - "-d", - "--date", - dest="date", - type=valid_date, - default=datetime.now(), - help="Date: yyyy/mm/dd", - ) - parser.add_argument( - "-p", - "--price", - dest="price", - type=check_positive_float, - required="-h" not in other_args, - help="Price purchased for asset", - ) - parser.add_argument( - "-f", - "--fees", - dest="fees", - type=check_positive_float, - help="Fees paid for transaction", - ) - - parser.add_argument( - "-a", - "--action", - type=lambda s: s.lower(), - dest="action", - choices=["buy", "sell", "interest", "deposit", "withdrawal"], - default="deposit" if "cash" in other_args else "buy", - help="Select what you did in the transaction", - ) - if other_args: - if "-n" not in other_args and "-h" not in other_args: - other_args.insert(0, "-n") ns_parser = parse_known_args_and_warn(parser, other_args) if not ns_parser: return - if ns_parser.type == "cash" and ns_parser.action not in [ - "deposit", - "withdrawal", - ]: - console.print("Cash can only be deposited or withdrew\n") - return - if ns_parser.type != "cash" and ns_parser.action in ["deposit", "withdrawal"]: - console.print("Only cash can be deposited or withdrew\n") - return - if ns_parser.type == "stock": - if not portfolio_helper.is_ticker(ns_parser.name): - console.print("Invalid ticker\n") + console.print() + inputs: Dict[str, Union[str, float, int]] = {} + type_ = input("Type (stock, cash): \n") + if type_ not in ["stock", "cash"]: + console.print("[red]Currently only stocks or cash supported.[/red]\n") + type_ = input("Type (stock, cash): \n") + if type_ not in ["stock", "cash"]: + console.print("[red]Two unsuccessful attempts. Exiting add.[/red]\n") return - data = { - "Name": ns_parser.name, - "Type": ns_parser.type, - "Quantity": ns_parser.quantity, - "Date": ns_parser.date, - "Price": ns_parser.price, - "Fees": ns_parser.fees, - "Premium": None, # ns_parser.premium - "Side": ns_parser.action, - } - self.portfolio = self.portfolio.append([data]) - self.portfolio.index = list(range(0, len(self.portfolio.values))) - console.print(f"{ns_parser.name.upper()} successfully added\n") + inputs["Type"] = type_.lower() + action = input("Action: (buy, sell, deposit, withdraw): \n").lower() - def call_rmv(self, _): - """Process rmv command""" - portfolio_view.show_df(self.portfolio, True) - to_rmv = int(input("\nType the index number you want to remove:\n")) - if 0 <= to_rmv < len(self.portfolio.index): - self.portfolio = self.portfolio.drop(self.portfolio.index[to_rmv]) - self.portfolio.index = list(range(0, len(self.portfolio.values))) - else: - console.print( - f"Invalid index please use an integer between 0 and {len(self.portfolio.index)-1}\n" + if type_ == "cash": + if action not in ["deposit", "withdraw"]: + console.print("Cash can only be deposit or withdraw\n") + action = input("Action: (buy, sell, deposit, withdraw): \n").lower() + if action not in ["deposit", "withdraw"]: + console.print( + "[red]Two unsuccessful attempts. Exiting add.[/red]\n" + ) + return + + elif type_ == "stock": + if action not in ["buy", "sell"]: + console.print("Stock can only be buy or sell\n") + if action not in ["buy", "sell"]: + console.print( + "[red]Two unsuccessful attempts. Exiting add.[/red]\n" + ) + return + + inputs["Side"] = action.lower() + inputs["Name"] = input("Name (ticker or cash [if depositing cash]):\n") + inputs["Date"] = valid_date(input("Purchase date (YYYY-MM-DD): \n")).strftime( + "%Y-%m-%d" + ) + inputs["Quantity"] = float(input("Quantity: \n")) + inputs["Price"] = float(input("Price per share: \n")) + inputs["Fees"] = float(input("Fees: \n")) + inputs["Premium"] = "" + if self.portfolio.empty: + self.portfolio = portfolio_model.Portfolio( + pd.DataFrame.from_dict(inputs, orient="index").T ) + console.print( + f"Portfolio successfully initiialized with {inputs['Name']}.\n" + ) + return + self.portfolio.add_trade(inputs) - def call_ar(self, other_args: List[str]): - """Process ar command""" + console.print(f"{inputs['Name']} successfully added\n") + + def call_build(self, other_args: List[str]): + """Process build command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, - prog="ar", - description="Create an annual report based on given portfolio", + prog="build", + description="Build portfolio from list of tickers and weights", ) parser.add_argument( - "-m", - "--market", - type=str, - dest="market", - default="SPY", - help="Choose a ticker to be the market asset", + "-s", + "--start", + help="Start date.", + dest="start", + default="2021-01-04", + type=valid_date, + required="-h" not in other_args, ) + parser.add_argument( + "-t", + "--tickers", + type=str, + help="List of symbols separated by commas (i.e AAPL,BTC,DOGE,SPY....)", + dest="tickers", + required="-h" not in other_args, + ) + parser.add_argument( + "-c", + "--class", + help="Asset class (stock, crypto, etf), separated by commas.", + dest="classes", + type=str, + required="-h" not in other_args, + ) + parser.add_argument( + "-w", + "--weights", + help="List of weights, separated by comma", + type=str, + dest="weights", + required="-h" not in other_args, + ) + parser.add_argument( + "-a", + "--amount", + help="Amount to allocate initially.", + dest="amount", + default=100_000, + type=check_positive, + ) + ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: - if not self.portfolio.empty: - val, hist = portfolio_model.convert_df(self.portfolio) - if not val.empty: - portfolio_view.Report( - val, hist, ns_parser.market, 365 - ).generate_report() - else: - console.print("Cannot generate a graph from an empty dataframe\n") - else: - console.print("Please add items to the portfolio\n") + list_of_tickers = ns_parser.tickers.split(",") + types = ns_parser.classes.split(",") + weights = [float(w) for w in ns_parser.weights.split(",")] + self.portfolio = portfolio_model.Portfolio.from_custom_inputs_and_weights( + start_date=ns_parser.start.strftime("%Y-%m-%d"), + list_of_symbols=list_of_tickers, + list_of_weights=weights, + list_of_types=types, + amount=ns_parser.amount, + ) + console.print() + + def call_al(self, other_args: List[str]): + """Process al command""" + parser = argparse.ArgumentParser( + add_help=False, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + prog="al", + description="Display allocation", + ) + ns_parser = parse_known_args_and_warn( + parser, other_args, export_allowed=EXPORT_ONLY_FIGURES_ALLOWED + ) + if ns_parser: + portfolio_view.display_allocation(self.portfolio, ns_parser.export) + console.print() + + # def call_ar(self, other_args: List[str]): + # """Process ar command""" + # parser = argparse.ArgumentParser( + # add_help=False, + # formatter_class=argparse.ArgumentDefaultsHelpFormatter, + # prog="ar", + # description="Create an annual report based on given portfolio", + # ) + # parser.add_argument( + # "-m", + # "--market", + # type=str, + # dest="market", + # default="SPY", + # help="Choose a ticker to be the market asset", + # ) + # ns_parser = parse_known_args_and_warn(parser, other_args) + # if ns_parser: + # if not self.portfolio.empty: + # self.portfolio.generate_holdings_from_trades() + # self.portfolio.add_benchmark(ns_parser.market) + # portfolio_view.Report( + # val, hist, ns_parser.market, 365 + # ).generate_report() + # else: + # console.print("Please add items to the portfolio\n") def call_rmr(self, other_args: List[str]): """Process rmr command""" @@ -337,71 +410,19 @@ class PortfolioController(BaseController): ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: if not self.portfolio.empty: - val, _ = portfolio_model.convert_df(self.portfolio) - if not val.empty: - df_m = yfinance_model.get_market(val.index[0], ns_parser.market) - returns, _ = portfolio_model.get_return(val, df_m, 365) - portfolio_view.plot_overall_return(returns, ns_parser.market, True) - else: - console.print("Cannot generate a graph from an empty dataframe\n") - else: - console.print("Please add items to the portfolio\n") - - def call_al(self, other_args: List[str]): - """Process al command""" - parser = argparse.ArgumentParser( - add_help=False, - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - prog="al", - description="Gives allocation", - ) - parser.add_argument( - "-l", - "--limit", - help="Number to show", - type=check_positive, - default=20, - dest="limit", - ) - parser.add_argument( - "-n", - "--noetf", - action="store_true", - default=False, - dest="no_etf_positions", - help="If etf positions should not be added to the portfolio", - ) - parser.add_argument( - "-g", - "--graph", - action="store_true", - default=False, - dest="graph", - help="Display table or pi chart of holdings", - ) - ns_parser = parse_known_args_and_warn(parser, other_args) - if not self.portfolio.empty: - val, hist = portfolio_model.convert_df(self.portfolio) - if not val.empty: - df = portfolio_model.get_allocation( - val, - hist, - self.portfolio, - ns_parser.limit, - ns_parser.no_etf_positions, + portfolio_view.display_returns_vs_bench( + self.portfolio, ns_parser.market ) - portfolio_view.display_allocation(df, ns_parser.graph) else: - console.print("Cannot generate a graph from an empty dataframe\n") - else: - console.print("Please add items to the portfolio\n") + console.print("[red]No portfolio loaded.[/red]") + console.print() - def call_mdd(self, other_args: List[str]): - """Process mdd command""" + def call_dd(self, other_args: List[str]): + """Process dd command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, - prog="mdd", + prog="dd", description="Show portfolio drawdown", ) ns_parser = parse_known_args_and_warn( @@ -409,10 +430,55 @@ class PortfolioController(BaseController): ) if ns_parser: if not self.portfolio.empty: - val, _ = portfolio_model.convert_df(self.portfolio) - if not val.empty: - df_m = yfinance_model.get_market(val.index[0], "SPY") - returns, _ = portfolio_model.get_return(val, df_m, 365) - portfolio_view.display_drawdown(returns[["return"]].dropna()) + self.portfolio.generate_holdings_from_trades() + self.portfolio.add_benchmark("SPY") + portfolio_view.display_drawdown(self.portfolio.portfolio_value) else: console.print("[red]No portfolio loaded.\n[/red]") + + def call_rolling(self, other_args: List[str]): + """Process rolling command""" + parser = argparse.ArgumentParser( + add_help=False, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + prog="rolling", + description="Show rolling portfolio metrics vs benchmark", + ) + parser.add_argument( + "-b", + "--benchmark", + type=str, + dest="benchmark", + default="SPY", + help="Choose a ticker to be the benchmark", + ) + parser.add_argument( + "-l", + "--length", + type=check_positive, + dest="length", + default=60, + help="Length of rolling window", + ) + parser.add_argument( + "-r", + "--rf", + type=check_positive_float, + dest="rf", + default=0.001, + help="Set risk free rate for calculations.", + ) + ns_parser = parse_known_args_and_warn( + parser, other_args, export_allowed=EXPORT_ONLY_FIGURES_ALLOWED + ) + if ns_parser: + if self.portfolio.empty: + console.print("[red]No portfolio loaded[/red].\n") + return + portfolio_view.display_rolling_stats( + self.portfolio, + length=ns_parser.length, + benchmark=ns_parser.benchmark, + risk_free_rate=ns_parser.rf, + ) + console.print() diff --git a/gamestonk_terminal/portfolio/portfolio_model.py b/gamestonk_terminal/portfolio/portfolio_model.py index bd2acdf8a18..fa76bfce592 100644 --- a/gamestonk_terminal/portfolio/portfolio_model.py +++ b/gamestonk_terminal/portfolio/portfolio_model.py @@ -1,31 +1,28 @@ """Portfolio Model""" __docformat__ = "numpy" -import logging -import math import os -from datetime import date, datetime, timedelta -from typing import List +from datetime import timedelta, datetime +from typing import Dict, List, Union +import logging import numpy as np import pandas as pd import statsmodels.api as sm from statsmodels.regression.rolling import RollingOLS +import yfinance as yf +from pycoingecko import CoinGeckoAPI -from gamestonk_terminal.decorators import log_start_end -from gamestonk_terminal.etf import stockanalysis_model from gamestonk_terminal.portfolio import ( portfolio_helper, - portfolio_view, - yfinance_model, ) from gamestonk_terminal.rich_config import console +from gamestonk_terminal.decorators import log_start_end -# pylint: disable=E1136 +# pylint: disable=E1136,W0201,R0902 # pylint: disable=unsupported-assignment-operation - - logger = logging.getLogger(__name__) +cg = CoinGeckoAPI() @log_start_end(log=logger) @@ -49,311 +46,6 @@ def save_df(df: pd.DataFrame, name: str) -> None: df.to_excel(path, index=False, engine="openpyxl") -@log_start_end(log=logger) -def load_df(name: str) -> pd.DataFrame: - """Load the portfolio from a csv - - Parameters - ---------- - name : str - The name of the string - - Returns - ---------- - data : pd.DataFrame - A DataFrame with historical trading information - """ - if ".csv" not in name and ".xlsx" not in name and ".json" not in name: - console.print( - "Please submit as 'filename.filetype' with filetype being csv, xlsx, or json\n" - ) - return pd.DataFrame() - - try: - if ".csv" in name: - df = pd.read_csv(f"gamestonk_terminal/portfolio/portfolios/{name}") - elif ".json" in name: - df = pd.read_json(f"gamestonk_terminal/portfolio/portfolios/{name}") - elif ".xlsx" in name: - df = pd.read_excel( - f"gamestonk_terminal/portfolio/portfolios/{name}", engine="openpyxl" - ) - - df.index = list(range(0, len(df.values))) - df["Name"] = df["Name"].str.lower() - df["Type"] = df["Type"].str.lower() - df["Date"] = pd.to_datetime(df["Date"], format="%Y/%m/%d") - - for item in ["Quantity", "Price", "Fees", "Premium"]: - result = any(df[item] < 0) - if result: - console.print( - f"The column '{item}' has a negative value. Ensure all values are positive." - ) - return pd.DataFrame() - - if len(df[~df["Type"].isin(["cash", "stock"])].index): - console.print( - "Warning: 'Type' other than 'cash' and 'stock' will be ignored." - ) - - if len( - df[ - ~df["Side"] - .str.lower() - .isin(["buy", "sell", "interest", "deposit", "withdrawal"]) - ].index - ): - console.print( - "Warning: 'Side' must be buy, sell, interest, deposit, or withdrawal" - ) - return pd.DataFrame() - - return df - except FileNotFoundError: - portfolio_view.load_info() - return pd.DataFrame() - - -@log_start_end(log=logger) -def add_values( - log: pd.DataFrame, changes: pd.DataFrame, cashes: pd.DataFrame -) -> pd.DataFrame: - """Creates a new df with performance results - - Parameters - ---------- - log : pd.DataFrame - The dataframe that will have daily holdings - changes : pd.DataFrame - Transactions that changed holdings - cashes : pd.DataFrame - Cash changing transactions - - Returns - ---------- - log : pd.DataFrame - A dataframe with daily holdings - """ - for index, _ in log.iterrows(): - # Add stocks to dataframe - values = changes[changes["Date"] == index] - if len(values.index) > 0: - for _, sub_row in values.iterrows(): - ticker = sub_row["Name"] - quantity = sub_row["Quantity"] - price = sub_row["Price"] - fees = sub_row["Fees"] - if math.isnan(fees): - fees = 0 - sign = -1 if sub_row["Side"].lower() == "sell" else 1 - pos1 = log.cumsum().at[index, ("Quantity", ticker)] > 0 - pos2 = (quantity * sign) > 0 - - if sub_row["Side"].lower() == "interest": - log.at[index, ("Cost Basis", ticker)] = ( - log.at[index, ("Cost Basis", ticker)] + quantity * price - ) - log.at[index, ("Cash", "Cash")] = log.at[ - index, ("Cash", "Cash") - ] - (quantity * price) - - elif ( - pos1 == pos2 - or log.cumsum().at[index, ("Quantity", ticker)] == 0 - or (quantity * sign) == 0 - ): - log.at[index, ("Quantity", ticker)] = ( - log.at[index, ("Quantity", ticker)] + quantity * sign - ) - log.at[index, ("Cost Basis", ticker)] = ( - log.at[index, ("Cost Basis", ticker)] - + fees - + quantity * sign * price - ) - log.at[index, ("Cash", "Cash")] = log.at[ - index, ("Cash", "Cash") - ] - (fees + quantity * sign * price) - else: - rev = ( - log.at[index, ("Profit", ticker)] + quantity * sign * price * -1 - ) - wa_cost = ( - quantity / log.cumsum().at[index, ("Quantity", ticker)] - ) * log.cumsum().at[index, ("Cost Basis", ticker)] - log.at[index, ("Profit", ticker)] = rev - wa_cost - fees - log.at[index, ("Cash", "Cash")] = ( - log.at[index, ("Cash", "Cash")] + rev - fees - ) - log.at[index, ("Quantity", ticker)] = ( - log.at[index, ("Quantity", ticker)] + quantity * sign - ) - log.at[index, ("Cost Basis", ticker)] = ( - log.at[index, ("Cost Basis", ticker)] - wa_cost - ) - cash_vals = cashes[cashes["Date"] == index] - if len(cash_vals.index) > 0: - for _, sub_row in cash_vals.iterrows(): - amount = sub_row["Price"] - quantity = sub_row["Quantity"] - if sub_row["Side"] == "deposit": - d = 1 - elif sub_row["Side"] == "withdrawal": - d = -1 - else: - raise ValueError("Cash type must be deposit or withdrawal") - log.at[index, ("Cash", "Cash")] = ( - log.at[index, ("Cash", "Cash")] + d * amount * quantity - ) - log.at[index, ("Cash", "User")] = ( - log.at[index, ("Cash", "User")] + d * amount * quantity - ) - return log - - -@log_start_end(log=logger) -def merge_dataframes( - log: pd.DataFrame, - hist: pd.DataFrame, - changes: pd.DataFrame, - divs: pd.DataFrame, - uniques: List[str], -) -> pd.DataFrame: - """Merge dataframes to create final dataframe - - Parameters - ---------- - log : pd.DataFrame - The new dataframe with daily holdings - hist : pd.DataFrame - Historical returns for stocks in portfolio - changes : pd.DataFrame - A log of past transactions - divs : pd.DataFrame - The dividend history for stocks in portfolio - uniques: List[str] - A list of stocks in the portfolio - - Returns - ---------- - comb : pd.DataFrame - Thew new aggregated dataframe - """ - comb = pd.merge(log, hist, how="left", left_index=True, right_index=True) - comb = comb.fillna(method="ffill") - comb = pd.merge(comb, divs, how="left", left_index=True, right_index=True) - comb = comb.fillna(0) - - for uni in uniques: - comb[("Quantity", uni)] = comb[("Quantity", uni)].cumsum() - comb[("Cost Basis", uni)] = comb[("Cost Basis", uni)].cumsum() - comb[("Cash", "Cash")] = comb[("Cash", "Cash")] + ( - comb[("Quantity", uni)] * comb[("Dividend", uni)] - ) - comb[("Holding", uni)] = ( - np.where( - comb[("Quantity", uni)] > 0, - comb[("Close", uni)], - (2 * comb[("Close", uni)][0] - comb[("Close", uni)]), - ) - * comb[("Quantity", uni)] - ) - comb[("Profit", uni)] = comb[("Profit", uni)].cumsum() - comb[("Cash", "Cash")] = comb[("Cash", "Cash")].cumsum() - if len(changes["Date"]) > 0: - comb["holdings"] = comb.groupby(level=0, axis=1).sum()["Holding"] - comb["profits"] = comb.groupby(level=0, axis=1).sum()["Profit"] - comb["total_prof"] = comb["holdings"] + comb["profits"] - comb["total_cost"] = comb.groupby(level=0, axis=1).sum()["Cost Basis"] - return comb - - -@log_start_end(log=logger) -def convert_df(portfolio: pd.DataFrame) -> pd.DataFrame: - """Converts a df from activity to daily holdings - - Parameters - ---------- - portfolio : pd.DataFrame - The dataframe of transactions - - Returns - ---------- - data : pd.DataFrame - A dataframe with daily holdings of portfolio - hist : pd.DataFrame - The historical performance of tickers in portfolio - """ - changes = portfolio.copy() - # Transactions sorted for only stocks and etfs - cashes = changes[changes["Type"] == "cash"] - if cashes.empty: - raise ValueError("Brokers require cash, input cash deposits") - changes = changes[(changes["Type"] == "stock") | (changes["Type"] == "etf")] - uniques = list(set(changes["Name"].tolist())) - if uniques: - hist = yfinance_model.get_stocks(uniques, min(changes["Date"])) - divs = yfinance_model.get_dividends(uniques) - else: - hist, divs = pd.DataFrame(), pd.DataFrame() - - divs = divs.fillna(0) - mini = min(cashes["Date"].to_list() + changes["Date"].to_list()) - days = pd.date_range(mini, date.today() - timedelta(days=1), freq="d") - zeros = [0 for _ in uniques] - data = [zeros + zeros + zeros + [0] for _ in days] - vals = ["Quantity", "Cost Basis", "Profit"] - arrays = [[x for _ in uniques for x in vals] + ["Cash"], uniques * 3 + ["Cash"]] - tuples = list(zip(*arrays)) - headers = pd.MultiIndex.from_tuples(tuples, names=["first", "second"]) - log = pd.DataFrame(data, columns=headers, index=days) - log[("Cash", "User")] = 0 - log = add_values(log, changes, cashes) - comb = merge_dataframes(log, hist, changes, divs, uniques) - - return comb, hist - - -@log_start_end(log=logger) -def get_return(df: pd.DataFrame, df_m: pd.DataFrame, n: int) -> pd.DataFrame: - """Adds cumulative returns to a holdings df - - Parameters - ---------- - df : pd.DataFrame - The dataframe of daily holdings - df_m : pd.DataFrame - The dataframe of market performance - n : int - The period to get returns for - - Returns - ---------- - comb : pd.DataFrame - Dataframe with holdings and returns - """ - df = df.copy() - df = df[df.index >= datetime.now() - timedelta(days=n + 1)] - comb = pd.merge(df, df_m, how="left", left_index=True, right_index=True) - comb = comb.fillna(method="ffill") - comb = comb.dropna() - comb["return"] = ( - comb[("Cash", "Cash")] - + (0 if "holdings" not in comb.columns else comb["holdings"]) - ) / ( - comb[("Cash", "Cash")].shift(1) - + comb[("Cash", "User")] - + (0 if "holdings" not in comb.columns else comb["holdings"].shift(1)) - ) - comb["return"] = comb["return"].fillna(1) - variance = np.var(comb["return"]) - comb["return"] = comb["return"].cumprod() - 1 - comb[("Market", "Return")] = ( - comb[("Market", "Close")] - comb[("Market", "Close")][0] - ) / comb[("Market", "Close")][0] - return comb, variance - - @log_start_end(log=logger) def get_rolling_beta( df: pd.DataFrame, hist: pd.DataFrame, mark: pd.DataFrame, n: pd.DataFrame @@ -400,139 +92,6 @@ def get_rolling_beta( return comb -@log_start_end(log=logger) -def fix_etf_allocation( - data: pd.DataFrame, - df_new: pd.DataFrame, - etf: str, - no_etf_positions: bool, - number: int, -): - """Adjusting the dataframe to etf positions, by adding etf holdings to identical portfolio positions or - if 'add_etf_positions' is true add a new position - - Parameters - ---------- - data : pd.DataFrame - The dataframe of positions - df_new: pd.DataFrame - A dataframe of etf holdings - etf: str - String of the etf - no_etf_positions: bool - If new positions should not be added to the 'data' dataframe for etf holdings - number: int - Number of etf positions to be checked - - Returns - ---------- - data: pd.DataFrame - etf adjusted dataframe - """ - i = 0 - length = int(df_new.index.size) - 1 - while i <= ( - length ^ ((number ^ length) & -(number < length)) - ): # gets minimum of number and length - if df_new.iloc[[i]].index[0].lower() in data.index: - change = ( - float(df_new.iloc[i]["% Of Etf"].replace("%", "")) - * data["value"].loc[data.index == etf][0] - / 100 - ) - data.loc[data.index == df_new.iloc[[i]].index[0].lower(), "value"] += change - elif not no_etf_positions: - change = ( - float(df_new.iloc[i]["% Of Etf"].replace("%", "")) - * data["value"].loc[data.index == etf][0] - / 100 - ) - data.loc[len(data.index)] = [f"{etf}_etf", change] - data.rename( - index={len(data.index) - 1: df_new.iloc[[i]].index[0].lower()}, - inplace=True, - ) - data.loc[data.index == etf, "value"] -= change - data.loc[data.index == etf, "value"] -= change - i += 1 - data.rename( - index={data.loc[data.index == etf].index[0]: f"{etf}_rest"}, inplace=True - ) - return data - - -@log_start_end(log=logger) -def get_allocation( - data: pd.DataFrame, - hist: pd.DataFrame, - portfolio: pd.DataFrame, - number: int, - no_etf_positions: bool, -) -> pd.DataFrame: - """Formatting the dataframe to an allocation dataframe of the positions - - Parameters - ---------- - data : pd.DataFrame - The dataframe of positions - hist: pd.DataFrame - A dataframe of the positions historical performances - portfolio: pd.DataFrame - Portfolio dataframe - number: int - Number of etf positions to be checked - no_etf_positions: bool - If new positions should not be added to the 'data' dataframe for etf holdings - - Returns - ---------- - df: pd.DataFrame - Dataframe of allocation - """ - # Fixing dataframe formatting - df = data["Quantity"].tail(1).transpose() - df.columns = ["amount"] - df = df.sort_index(axis=0, ascending=True) - hist = hist.tail(1).transpose() - hist = hist.reset_index(["first"]) - hist.columns = ["position", "value"] - hist = pd.DataFrame(hist["value"]) - - i = 0 - value = [] - while i < df.index.size: - value.append(df["amount"].iloc[i] * hist["value"].iloc[i]) - i += 1 - df["value"] = value - - i = 0 - length = df.index.size - while i < length: - row_index = df.iloc[[i]].index[0] - if ( - portfolio["Type"].loc[portfolio["Name"] == row_index].to_list()[0] == "etf" - ): # check if position is an etf - df = fix_etf_allocation( - df, - stockanalysis_model.get_etf_holdings(row_index), - row_index, - no_etf_positions, - number, - ) - if df.iloc[i].loc["value"] < 0: - # Converting negative positions to "shorts" in order to correctly calculate allocation - df.loc[row_index, "value"] = abs(df.loc[row_index, "value"]) - df.loc[row_index, "amount"] = abs(df.loc[row_index, "amount"]) - df.rename(index={row_index: f"short_{row_index}"}, inplace=True) - i += 1 - - total_value = df["value"].sum() - df["pct_allocation"] = df["value"] / total_value * 100 # calculate allocation - df = df.sort_values(by="pct_allocation", ascending=False) - - return df - - @log_start_end(log=logger) def get_main_text(df: pd.DataFrame) -> str: """Get main performance summary from a dataframe with returns @@ -639,3 +198,343 @@ def calculate_drawdown(input_series: pd.Series, is_returns: bool = False) -> pd. rolling_max = input_series.cummax() drawdown = (input_series - rolling_max) / rolling_max return drawdown + + +class Portfolio: + """ + Class for portfolio analysis in GST + + Attributes + ------- + portfolio_value: pd.Series + Series containing value at each day + returns: pd.Series + Series containing daily returns + ItemizedReturns: pd.DataFrame + Dataframe of Holdings by class + portfolio: pd.DataFrame + Dataframe containing all relevant holdings data + + Methods + ------- + add_trade: + Adds a trade to the dataframe of trades + generate_holdings_from_trades: + Takes a list of trades and converts to holdings on each day + get_yahoo_close_prices: + Gets close prices or adj close prices from yfinance + add_benchmark + Adds a benchmark to the class + + """ + + @log_start_end(log=logger) + def __init__(self, trades: pd.DataFrame = pd.DataFrame(), rf=0): + """Initialize Portfolio class""" + # Allow for empty initialization + self.empty = True + self.rf = rf + if not trades.empty: + if "cash" not in trades.Name.to_list(): + console.print( + "[red]No initial cash deposit. Calculations may be off as this assumes trading from a " + "funded account[/red]." + ) + # Load in trades df and do some quick editing + trades.Name = trades.Name.map(lambda x: x.upper()) + trades["Side"] = trades["Side"].map( + lambda x: 1 + if x.lower() in ["deposit", "buy"] + else (-1 if x.lower() == "sell" else 0) + ) + trades["Value"] = trades.Quantity * trades.Price * trades.Side + # Make selling negative for cumulative sum of quantity later + trades["Quantity"] = trades["Quantity"] * trades["Side"] + # Should be simply extended for crypto/bonds etc + # Treat etf as stock for yfinance historical. + self._stock_tickers = list( + set(trades[trades.Type == "stock"].Name.to_list()) + ) + + self._etf_tickers = list(set(trades[trades.Type == "etf"].Name.to_list())) + + self._crypto_tickers = list( + set(trades[trades.Type == "crypto"].Name.to_list()) + ) + self._start_date = trades.Date[0] + # Copy pandas notation + self.empty = False + self.trades = trades + self.portfolio = pd.DataFrame() + + @log_start_end(log=logger) + def add_trade(self, trade_info: Dict): + self.trades = self.trades.append([trade_info]) + self.empty = False + + @log_start_end(log=logger) + def add_rf(self, risk_free_rate: float): + """Sets the risk free rate for calculation purposes""" + self.rf = risk_free_rate + + @log_start_end(log=logger) + def generate_holdings_from_trades(self, yfinance_use_close: bool = False): + """Generates portfolio data from list of trades""" + # Load historical prices from yahoo (option to get close instead of adj close) + self.get_yahoo_close_prices(use_close=yfinance_use_close) + self.get_crypto_yfinance() + + # Pivot list of trades on Name. This will give a multi-index df where the multiindex are the individual ticker + # and the values from the table + portfolio = self.trades.pivot( + index="Date", + columns="Name", + values=["Quantity", "Price", "Fees", "Premium", "Side", "Value"], + ) + # Merge with historical close prices (and fillna) + portfolio = pd.merge( + portfolio, + self._historical_prices, + how="right", + left_index=True, + right_index=True, + ).fillna(0) + + is_there_stock_or_etf = self._stock_tickers + self._etf_tickers + + # Merge with crypto + if self._crypto_tickers: + portfolio = pd.merge( + portfolio, + self._historical_crypto, + how="left" if is_there_stock_or_etf else "right", + left_index=True, + right_index=True, + ).fillna(0) + else: + portfolio[pd.MultiIndex.from_product([["Close"], ["crypto"]])] = 0 + + # Add cumulative Quantity held + portfolio["Quantity"] = portfolio["Quantity"].cumsum() + + # Add holdings for each 'type'. Will check for tickers matching type. + + if self._stock_tickers: + # Find end of day holdings for each stock + portfolio[ + pd.MultiIndex.from_product([["StockHoldings"], self._stock_tickers]) + ] = ( + portfolio["Quantity"][self._stock_tickers] + * portfolio["Close"][self._stock_tickers] + ) + else: + portfolio[pd.MultiIndex.from_product([["StockHoldings"], ["temp"]])] = 0 + + if self._etf_tickers: + # Find end of day holdings for each ETF + portfolio[ + pd.MultiIndex.from_product([["ETFHoldings"], self._etf_tickers]) + ] = ( + portfolio["Quantity"][self._etf_tickers] + * portfolio["Close"][self._etf_tickers] + ) + else: + portfolio[pd.MultiIndex.from_product([["ETFHoldings"], ["temp"]])] = 0 + if self._crypto_tickers: + # Find end of day holdings for each stock + portfolio[ + pd.MultiIndex.from_product([["CryptoHoldings"], self._crypto_tickers]) + ] = ( + portfolio["Quantity"][self._crypto_tickers] + * portfolio["Close"][self._crypto_tickers] + ) + else: + portfolio[pd.MultiIndex.from_product([["CryptoHoldings"], ["temp"]])] = 0 + + # Find amount of cash held in account. Defined as deposited cash - stocks bought + stocks sold + portfolio["CashHold"] = portfolio["Value"]["CASH"] - portfolio["Value"][ + self._stock_tickers + self._etf_tickers + self._crypto_tickers + ].sum(axis=1) + + # Subtract Fees or Premiums from cash holdings + portfolio["CashHold"] = ( + portfolio["CashHold"] + - portfolio["Fees"].sum(axis=1) + - portfolio["Premium"].sum(axis=1) + ) + portfolio["CashHold"] = portfolio["CashHold"].cumsum() + + portfolio["TotalHoldings"] = ( + portfolio["CashHold"] + + portfolio["StockHoldings"].sum(axis=1) + + portfolio["ETFHoldings"].sum(axis=1) + + portfolio["CryptoHoldings"].sum(axis=1) + ) + + self.portfolio_value = portfolio["TotalHoldings"] + self.returns = portfolio["TotalHoldings"].pct_change().dropna() + self.ItemizedHoldings = pd.DataFrame( + { + "Stocks": portfolio["StockHoldings"][self._stock_tickers].sum(axis=1), + "ETFs": portfolio["ETFHoldings"][self._etf_tickers].sum(axis=1), + "Crypto": portfolio["CryptoHoldings"][self._crypto_tickers].sum(axis=1), + "Cash": portfolio["CashHold"], + } + ) + self.portfolio = portfolio.copy() + + # TODO: Add back dividends + @log_start_end(log=logger) + def get_yahoo_close_prices(self, use_close: bool = False): + """Gets historical adj close prices for tickers in list of trades""" + tickers_to_download = self._stock_tickers + self._etf_tickers + if tickers_to_download: + if not use_close: + self._historical_prices = yf.download( + tickers_to_download, start=self._start_date, progress=False + )["Adj Close"] + else: + self._historical_prices = yf.download( + tickers_to_download, start=self._start_date, progress=False + )["Close"] + + # Adjust for case of only 1 ticker + if len(tickers_to_download) == 1: + self._historical_prices = pd.DataFrame(self._historical_prices) + self._historical_prices.columns = tickers_to_download + + else: + data_for_index_purposes = yf.download( + "AAPL", start=self._start_date, progress=False + )["Adj Close"] + self._historical_prices = pd.DataFrame() + self._historical_prices.index = data_for_index_purposes.index + self._historical_prices["stock"] = 0 + + self._historical_prices["CASH"] = 1 + + # Make columns a multi-index. This helps the merging. + self._historical_prices.columns = pd.MultiIndex.from_product( + [["Close"], self._historical_prices.columns] + ) + + @log_start_end(log=logger) + def get_crypto_yfinance(self): + """Gets historical coin data from coingecko""" + if self._crypto_tickers: + list_of_coins = [f"{coin}-USD" for coin in self._crypto_tickers] + self._historical_crypto = yf.download( + list_of_coins, start=self._start_date, progress=False + )["Close"] + + if len(list_of_coins) == 1: + self._historical_crypto = pd.DataFrame(self._historical_crypto) + self._historical_crypto.columns = list_of_coins + + self._historical_crypto.columns = pd.MultiIndex.from_product( + [["Close"], [col[:-4] for col in self._historical_crypto.columns]] + ) + + else: + self._historical_crypto = pd.DataFrame() + self._historical_crypto[ + pd.MultiIndex.from_product([["Close"], ["crypto"]]) + ] = 0 + + @log_start_end(log=logger) + def add_benchmark(self, benchmark: str): + """Adds benchmark dataframe""" + self.benchmark = yf.download(benchmark, start=self._start_date, progress=False)[ + "Adj Close" + ] + self.benchmark_returns = self.benchmark.pct_change().dropna() + + @classmethod + @log_start_end(log=logger) + def from_csv(cls, csv_path: str): + """Class method that generates a portfolio object from a csv file + + Parameters + ---------- + csv_path: str + Path to csv of trade data + + Returns + ------- + Portfolio + Initialized portfolio object + """ + # Load in a list of trades + trades = pd.read_csv(csv_path) + # Convert the date to what pandas understands + trades.Date = pd.to_datetime(trades.Date) + # Build the portfolio object + return cls(trades) + + @classmethod + @log_start_end(log=logger) + def from_custom_inputs_and_weights( + cls, + start_date: str, + list_of_symbols: List[str], + list_of_weights: List[float], + list_of_types: List[str], + amount: float = 100_000, + ): + """Create a class instance from supplied weights and tickers. + This first will generate a dataframe of trades then pass to generator. + + Parameters + ---------- + start_date: str + Start date + list_of_symbols: List[str] + List of symbols to consider + list_of_weights: List[float] + List of weights (in theory negative should be fine), Must add to 1 + list_of_types: List[str] + List of asset type (stock or crypto) + amount: float + Amount for portfolio allocation + + Returns + ------- + Portfolio + Class instance + """ + if not np.isclose(np.sum(list_of_weights), 1, 0.03): + console.print("[red]Weights do not add to 1[/red].") + return cls() + list_of_amounts = [weight * amount for weight in list_of_weights] + # Name,Type,Quantity,Date,Price,Fees,Premium,Side + inputs: Dict[str, List[Union[str, float, int]]] = {} + inputs["Name"] = ["cash"] + inputs["Type"] = ["cash"] + inputs["Quantity"] = [1] + inputs["Price"] = [amount] + inputs["Fees"] = [0] + inputs["Premium"] = [0] + inputs["Side"] = ["deposit"] + inputs["Date"] = [start_date] + for ticker, type_, amounts in zip( + list_of_symbols, list_of_types, list_of_amounts + ): + inputs["Name"].append(ticker) + inputs["Type"].append(type_) + inputs["Fees"].append(0) + inputs["Premium"].append(0) + inputs["Side"].append("buy") + if type_ == "crypto": + ticker += "-USD" + # Find how much of each ticker we can buy + temp = yf.download(ticker, start=start_date, progress=False)["Adj Close"] + price_on_date = temp[0] + inputs["Date"].append(temp.index[0].strftime("%Y-%m-%d")) + inputs["Quantity"].append(amounts / price_on_date) + inputs["Price"].append(price_on_date) + trades = pd.DataFrame.from_dict(inputs) + trades.Date = pd.to_datetime(trades.Date) + # Make sure to fix the 'cash' date if it is weekend + if trades.Date[1] != trades.Date[0]: + trades.Date[0] = trades.Date[1] + return cls(trades) diff --git a/gamestonk_terminal/portfolio/portfolio_view.py b/gamestonk_terminal/portfolio/portfolio_view.py index a403de9afc0..c97ee48d063 100644 --- a/gamestonk_terminal/portfolio/portfolio_view.py +++ b/gamestonk_terminal/portfolio/portfolio_view.py @@ -2,36 +2,35 @@ __docformat__ = "numpy" import logging -import os +from typing import List, Optional from datetime import datetime from io import BytesIO from os import path -from typing import List +import os -import matplotlib.ticker as mtick import numpy as np import pandas as pd -from matplotlib import pyplot as plt -from matplotlib.lines import Line2D -from pypfopt import plotting from reportlab.lib.pagesizes import letter -from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from matplotlib.lines import Line2D +from matplotlib import pyplot as plt +import matplotlib.ticker as mtick +from pypfopt import plotting -from gamestonk_terminal import feature_flags as gtff from gamestonk_terminal.config_plot import PLOT_DPI -from gamestonk_terminal.decorators import log_start_end -from gamestonk_terminal.helper_funcs import ( - export_data, - get_rf, - plot_autoscale, - print_rich_table, -) +from gamestonk_terminal import feature_flags as gtff from gamestonk_terminal.portfolio import ( portfolio_model, - reportlab_helpers, yfinance_model, ) +from gamestonk_terminal.portfolio import reportlab_helpers +from gamestonk_terminal.helper_funcs import ( + get_rf, + plot_autoscale, + export_data, +) +from gamestonk_terminal.decorators import log_start_end from gamestonk_terminal.portfolio.portfolio_optimization import optimizer_model from gamestonk_terminal.rich_config import console @@ -65,25 +64,177 @@ In order to load a CSV do the following: @log_start_end(log=logger) -def show_df(df: pd.DataFrame, show: bool) -> None: - """Shows the given dataframe +def display_returns_vs_bench( + portfolio: portfolio_model.Portfolio, + benchmark: str = "SPY", + external_axes: Optional[plt.Axes] = None, +): + """Display portfolio returns vs benchmark Parameters ---------- - df : pd.DataFrame - The dataframe to be shown - show : bool - Whether to show the dataframe index + portfolio: Portfolio + Custom portfolio object with trade list + benchmark: str + Symbol for benchmark. Defaults to SPY + external_axes: plt.Axes + Optional axes to display plot on + """ + if not external_axes: + fig, ax = plt.subplots(figsize=plot_autoscale(), dpi=PLOT_DPI) + else: + ax = external_axes - df = df.dropna(how="all", axis=1).fillna("") + portfolio.generate_holdings_from_trades() + portfolio.add_benchmark(benchmark) - print_rich_table( - df, - headers=list(df.columns), - show_index=show, + cumulative_returns = (1 + portfolio.returns).cumprod() + benchmark_c_returns = (1 + portfolio.benchmark_returns).cumprod() + + ax.plot(cumulative_returns.index, cumulative_returns, label="Portfolio") + ax.plot(benchmark_c_returns.index, benchmark_c_returns, label="Benchmark") + ax.grid() + ax.legend(loc=0) + if not external_axes: + if gtff.USE_ION: + plt.ion() + fig.autofmt_xdate() + fig.tight_layout() + plt.show() + + +@log_start_end(log=logger) +def display_allocation(portfolio: portfolio_model.Portfolio, export: str = ""): + """Display allocation of assets vs time + + Parameters + ---------- + portfolio: Portfolio + Portfolio object with trades loaded + export: str + Format to export plot + """ + try: + portfolio.generate_holdings_from_trades() + all_holdings = pd.concat( + [ + portfolio.portfolio["StockHoldings"], + portfolio.portfolio["ETFHoldings"], + portfolio.portfolio["CryptoHoldings"], + ], + axis=1, + ) + all_holdings = all_holdings.drop(columns=["temp"]) + fig, ax = plt.subplots(figsize=plot_autoscale(), dpi=PLOT_DPI) + all_holdings.plot(ax=ax) + ax.set_title("Individual Holdings") + ax.legend(loc="upper left") + fig.tight_layout(pad=2) + if gtff.USE_ION: + plt.ion() + except Exception as e: + print(e) + plt.show() + export_data( + export, + os.path.dirname(os.path.abspath(__file__)), + "rolling", ) - console.print("") + + +@log_start_end(log=logger) +def display_rolling_stats( + portfolio: portfolio_model.Portfolio, + length: int = 60, + benchmark: str = "SPY", + risk_free_rate: float = 0, + external_axes: Optional[List[plt.Axes]] = None, + export: str = "", +): + """Display portfolio returns vs benchmark + + Parameters + ---------- + portfolio: Portfolio + Custom portfolio object with trade list + length: int + Length of rolling window + benchmark: str + Symbol for benchmark. Defaults to SPY + risk_free_rate: float + Value to use for risk free rate in sharpe/other calculations + external_axes: Optional[List[plt.Axes]] + Optional axes to display plot on + export: str + Export to file + """ + portfolio.generate_holdings_from_trades() + portfolio.add_benchmark(benchmark) + portfolio.add_rf(risk_free_rate) + if not external_axes: + fig, ax = plt.subplots(4, 1, figsize=(8, 8), dpi=PLOT_DPI, sharex=True) + else: + ax = external_axes + rolling_volatility = portfolio.returns.rolling(length).std() + rolling_volatility_bench = portfolio.benchmark_returns.rolling(length).std() + + rolling_sharpe = portfolio.returns.rolling(length).apply( + lambda x: (x.mean() - risk_free_rate) / x.std() + ) + rolling_sharpe_bench = portfolio.benchmark_returns.rolling(length).apply( + lambda x: (x.mean() - risk_free_rate) / x.std() + ) + + rolling_volatility.plot(ax=ax[1]) + rolling_volatility_bench.plot(ax=ax[1]) + ax[1].set_title("Rolling Volatility") + + rolling_sharpe.plot(ax=ax[2]) + rolling_sharpe_bench.plot(ax=ax[2]) + ax[2].set_title("Rolling Sharpe Ratio") + + # Rolling beta is defined as Cov(Port,Bench)/var(Bench) + covs = ( + pd.DataFrame( + {"Portfolio": portfolio.returns, "Benchmark": portfolio.benchmark_returns} + ) + .dropna(axis=0) + .rolling(length) + .cov() + .unstack() + .dropna() + ) + rolling_beta = covs["Portfolio"]["Benchmark"] / covs["Benchmark"]["Benchmark"] + rolling_beta.plot(ax=ax[3]) + ax[3].set_title("Rolling Beta to Benchmark") + + c_returns = (1 + portfolio.returns).cumprod() + bench_c_rets = (1 + portfolio.benchmark_returns).cumprod() + + ax[0].plot(c_returns.index, c_returns) + ax[0].plot(bench_c_rets.index, bench_c_rets) + ax[0].set_title("Cumulative Returns") + + if not external_axes: + + for a in ax[0], ax[1], ax[2]: + a.legend(["Portfolio", "Benchmark"], loc="upper left") + for a in ax[0], ax[1], ax[2], ax[3]: + a.set_xlim(portfolio.returns.index[0], portfolio.returns.index[-1]) + a.set_xlabel([]) + a.grid("on") + + ax[3].set_xlabel("Date") + if gtff.USE_ION: + plt.ion() + fig.tight_layout(pad=2) + export_data( + export, + os.path.dirname(os.path.abspath(__file__)), + "rolling", + ) + plt.show() @log_start_end(log=logger) @@ -258,39 +409,39 @@ def plot_ef( return ImageReader(imgdata) -@log_start_end(log=logger) -def display_allocation(data: pd.DataFrame, graph: bool): - """Displays allocation - Parameters - ---------- - data: pd.DataFrame - The portfolio allocation dataframe - graph: bool - If pie chart shall be displayed with table""" - - print_rich_table(data, headers=list(data.columns), title="Allocation") - console.print("") - - if graph: - graph_data = data[data["pct_allocation"] >= 5].copy() - if not graph_data.empty: - graph_data.loc["Other"] = [ - "NA", - data["value"].sum() - graph_data["value"].sum(), - 100 - graph_data["value"].sum(), - ] - labels = graph_data.index.values - sizes = graph_data["value"].to_list() - else: - labels = data.index.values - sizes = data["value"].to_list() - fig, ax = plt.subplots() - ax.pie(sizes, labels=labels, autopct="%1.1f%%", startangle=90) - ax.axis("equal") - ax.set_title("Portfolio Allocation") - fig.set_tight_layout(True) - - plt.show() +# @log_start_end(log=logger) +# def display_allocation2(data: pd.DataFrame, graph: bool): +# """Displays allocation +# Parameters +# ---------- +# data: pd.DataFrame +# The portfolio allocation dataframe +# graph: bool +# If pie chart shall be displayed with table""" +# +# print_rich_table(data, headers=list(data.columns), title="Allocation") +# console.print("") +# +# if graph: +# graph_data = data[data["pct_allocation"] >= 5].copy() +# if not graph_data.empty: +# graph_data.loc["Other"] = [ +# "NA", +# data["value"].sum() - graph_data["value"].sum(), +# 100 - graph_data["value"].sum(), +# ] +# labels = graph_data.index.values +# sizes = graph_data["value"].to_list() +# else: +# labels = data.index.values +# sizes = data["value"].to_list() +# fig, ax = plt.subplots() +# ax.pie(sizes, labels=labels, autopct="%1.1f%%", startangle=90) +# ax.axis("equal") +# ax.set_title("Portfolio Allocation") +# fig.set_tight_layout(True) +# +# plt.show() @log_start_end(log=logger) @@ -308,10 +459,10 @@ def display_drawdown(holdings: pd.DataFrame, export: str = ""): drawdown = portfolio_model.calculate_drawdown(holdings) fig, ax = plt.subplots(2, 1, figsize=plot_autoscale(), dpi=PLOT_DPI) ax[0].plot(holdings.index, holdings) - ax[0].set_title("Cumulative Returns") + ax[0].set_title("Holdings") ax[1].plot(holdings.index, drawdown) - ax[1].fill_between(holdings.index, np.asarray(drawdown["return"]), alpha=0.4) + ax[1].fill_between(holdings.index, np.asarray(drawdown), alpha=0.4) ax[1].set_title("Portfolio Drawdown") ax[0].grid() ax[1].grid() @@ -324,13 +475,13 @@ def display_drawdown(holdings: pd.DataFrame, export: str = ""): export_data( export, os.path.dirname(os.path.abspath(__file__)), - "mdd", + "dd", ) class Report: @log_start_end(log=logger) - def __init__(self, df: pd.DataFrame, hist: pd.DataFrame, m_tick: str, n: int): + def __init__(self, df: pd.DataFrame, hist: pd.DataFrame, m_tick: str): """Generate financial reports. Financial reports allow users to show the how they have been performing in trades. This allows for a simple way to show progress and analyze metrics @@ -342,8 +493,8 @@ class Report: The dataframe with previous holdings information hist : pd.DataFrame The dataframe with previous prices for stocks in the portfolio - m_tick : str - The market asset to be identified + df_m : pd.DataFrame + Dataframe of benchmark n : int The number of days to analyze @@ -361,7 +512,8 @@ class Report: self.hist = hist self.m_tick = m_tick self.df_m = yfinance_model.get_market(self.df.index[0], self.m_tick) - self.returns, self.variance = portfolio_model.get_return(df, self.df_m, n) + # self.returns, self.variance = portfolio_model.get_return(df, self.df_m, n) + self.returns = pd.DataFrame() self.rf = get_rf() self.betas = portfolio_model.get_rolling_beta( self.df, self.hist, self.df_m, 365 diff --git a/gamestonk_terminal/portfolio/portfolios/bitcoin.csv b/gamestonk_terminal/portfolio/portfolios/bitcoin.csv new file mode 100644 index 00000000000..fce32161b5d --- /dev/null +++ b/gamestonk_terminal/portfolio/portfolios/bitcoin.csv @@ -0,0 +1,3 @@ +Name,Type,Quantity,Date,Price,Fees,Premium,Side +cash,cash,1,2021/7/20,30000,0,0,deposit +btc,crypto,1,2021/7/20,29790,0,0,Buy \ No newline at end of file diff --git a/gamestonk_terminal/portfolio/portfolios/example.csv b/gamestonk_terminal/portfolio/portfolios/example.csv index 72b908ba6e2..016ef553a18 100644 --- a/gamestonk_terminal/portfolio/portfolios/example.csv +++ b/gamestonk_terminal/portfolio/portfolios/example.csv @@ -1,11 +1,6 @@ Name,Type,Quantity,Date,Price,Fees,Premium,Side -cash,cash,1.0,2019/6/22,2000.0,,,deposit -amzn,stock,300.0,2019/10/21,2000.0,20.0,,Buy -amzn,stock,100.0,2019/10/21,2100.0,20.0,,Sell -amc,stock,400.0,2019/10/21,20.0,20.0,,Buy -tsla,stock,10.0,2020/10/10,200.0,20.0,,Sell -gme,stock,1.0,2020/10/20,50.0,,,Buy -gme,stock,1.0,2021/10/20,50.0,,,Buy -amzn,stock,50.0,2021/10/20,2000.0,20.0,,Sell -tsla,stock,15.0,2021/10/12,200.0,20.0,,Sell -tsla,stock,15.0,2021/10/21,20,0,,Interest +cash,cash,1.0,2021/3/24,2000.0,0,0,deposit +aapl,stock,10,2021/3/24,120.7,0,0,Buy +msft,stock,3,2021/3/26,232.8,0,0,Buy +btc,crypto,.006,2021/5/21,37430,0,0,Buy +aapl,stock,5,2021/7/2,139.8,0,0,Sell diff --git a/gamestonk_terminal/portfolio/portfolios/james.csv b/gamestonk_terminal/portfolio/portfolios/james.csv deleted file mode 100644 index 73fd23447ed..00000000000 --- a/gamestonk_terminal/portfolio/portfolios/james.csv +++ /dev/null @@ -1,5 +0,0 @@ -Name,Type,Quantity,Date,Price,Fees,Premium,Side -cash,cash,1.0,2021/3/24,2000.0,,,deposit -aapl,stock,10,2021/3/24,120.7,0,0,Buy -msft,stock,3,2021/3/26,232.8,0,0,Buy -aapl,stock,5,2021/7/2,139.8,0,0,Sell diff --git a/gamestonk_terminal/portfolio/yfinance_model.py b/gamestonk_terminal/portfolio/yfinance_model.py index 64d4d107e33..a998c0380d0 100644 --- a/gamestonk_terminal/portfolio/yfinance_model.py +++ b/gamestonk_terminal/portfolio/yfinance_model.py @@ -2,20 +2,14 @@ __docformat__ = "numpy" import difflib -import logging -from datetime import datetime, timedelta from typing import List +from datetime import datetime, timedelta -import pandas as pd import yfinance as yf - -from gamestonk_terminal.decorators import log_start_end +import pandas as pd from gamestonk_terminal.stocks.sector_industry_analysis import financedatabase_model -logger = logging.getLogger(__name__) - -@log_start_end(log=logger) def get_stocks(tickers: List[str], start: datetime) -> pd.DataFrame: """Gets historic data for list of tickers @@ -49,7 +43,6 @@ def get_stocks(tickers: List[str], start: datetime) -> pd.DataFrame: return df -@log_start_end(log=logger) def get_dividends(tickers: List[str]) -> pd.DataFrame: """Past dividends for list of tickers @@ -71,7 +64,6 @@ def get_dividends(tickers: List[str]) -> pd.DataFrame: return pd.concat(dfs) -@log_start_end(log=logger) def get_market(start: datetime, ticker: str = "SPY") -> pd.DataFrame: """Get historical data for market asset @@ -95,7 +87,6 @@ def get_market(start: datetime, ticker: str = "SPY") -> pd.DataFrame: return df.to_frame(name=("Market", "Close")) -@log_start_end(log=logger) def get_country(ticker): country = "NA" data = yf.utils.get_json(f"https://finance.yahoo.com/quote/{ticker}") diff --git a/tests/gamestonk_terminal/portfolio/test_portfolio_controller.py b/tests/gamestonk_terminal/portfolio/test_portfolio_controller.py index 3c6dac8a5c2..d7835a28d9a 100644 --- a/tests/gamestonk_terminal/portfolio/test_portfolio_controller.py +++ b/tests/gamestonk_terminal/portfolio/test_portfolio_controller.py @@ -222,7 +222,7 @@ def test_call_func_expect_queue(expected_queue, func, queue): ( "call_show", [], - "portfolio_view.show_df", + "", [], dict(), ), diff --git a/tests/gamestonk_terminal/portfolio/txt/test_portfolio_controller/test_print_help.txt b/tests/gamestonk_terminal/portfolio/txt/test_portfolio_controller/test_print_help.txt index ba79982304c..192862088fd 100644 --- a/tests/gamestonk_terminal/portfolio/txt/test_portfolio_controller/test_print_help.txt +++ b/tests/gamestonk_terminal/portfolio/txt/test_portfolio_controller/test_print_help.txt @@ -8,13 +8,14 @@ Portfolio: save save your portfolio for future use show show existing transactions add add a security to your portfolio - rmv remove a security from your portfolio + init initialize empty portfolio + build build portfolio from list of tickers and weights -Reports: - ar annual report for performance of a given portfolio +Loaded: None Graphs: + rolling display rolling metrics of portfolio and benchmark rmr graph your returns versus the market's returns - al displays the allocation of the portfolio - mdd display portfolio drawdown + dd display portfolio drawdown + al display allocation to given assets over period diff --git a/website/content/portfolio/al/_index.md b/website/content/portfolio/al/_index.md index 3a28bf4570c..771184f3a14 100644 --- a/website/content/portfolio/al/_index.md +++ b/website/content/portfolio/al/_index.md @@ -1,13 +1,11 @@ ``` -usage: al [-l] [-n] [-g] [-h] +usage: al [-h] [--export {png,jpg,pdf,svg}] ``` - -Displays the allocation of the portfolio - +Display allocation ``` optional arguments: - -l, --limit number of etf positions to go through (default: 20) - -n, --noetf if etf positions should not be added to the portfolio (default: False) - -g, --graph display pi chart of holdings (default: False) -h, --help show this help message (default: False) -``` + --export {png,jpg,pdf,svg} + Export figure into png, jpg, pdf, svg (default: ) + +``` \ No newline at end of file diff --git a/website/content/portfolio/build/_index.md b/website/content/portfolio/build/_index.md new file mode 100644 index 00000000000..f72bb62ac10 --- /dev/null +++ b/website/content/portfolio/build/_index.md @@ -0,0 +1,33 @@ +``` +usage: build [-s START] [-t TICKERS] [-c CLASSES] [-w WEIGHTS] [-a AMOUNT] [-h] +``` + +Build portfolio from list of tickers and weights + +``` +optional arguments: + -s START, --start START + Start date. (default: 2021-01-04) + -t TICKERS, --tickers TICKERS + List of symbols separated by commas (i.e AAPL,BTC,DOGE,SPY....) (default: None) + -c CLASSES, --class CLASSES + Asset class (stock, crypto, etf), separated by commas. (default: None) + -w WEIGHTS, --weights WEIGHTS + List of weights, separated by comma (default: None) + -a AMOUNT, --amount AMOUNT + Amount to allocate initially. (default: 100000) + -h, --help show this help message (default: False) + +``` + +The method of listing trades has resulted in some confusion, so this method is meant to just give portfolio statistics +for a custom assortment of tickers and weights. The drawback is that this assumes everything is bought on the same day +at 'the closing price'. + +An example of a build is below. This assumes that $100,000 dollars was invested on Jan 5 2018, +50% allocated to AAPL, and then 25% to bitcoin and 25% to NFLX. + +``` +build -t aapl,btc,nflx -c stock,crypto,stock -w .5,.25,.25 -s 2018-01-05 -a 100_000 +``` +Currently the weights should add to 1 (so technically shorting is allowed as long as you have a net weights of 1). diff --git a/website/content/portfolio/mdd/_index.md b/website/content/portfolio/dd/_index.md similarity index 100% rename from website/content/portfolio/mdd/_index.md rename to website/content/portfolio/dd/_index.md diff --git a/website/content/portfolio/init/_index.md b/website/content/portfolio/init/_index.md new file mode 100644 index 00000000000..5617a7546f5 --- /dev/null +++ b/website/content/portfolio/init/_index.md @@ -0,0 +1,9 @@ +``` +usage: init [-h] +``` +Re-initialize with empty portfolio. + +``` +optional arguments: + -h, --help show this help message (default: False) +``` \ No newline at end of file diff --git a/website/content/portfolio/rmv/_index.md b/website/content/portfolio/rmv/_index.md deleted file mode 100644 index a96f9602fc9..00000000000 --- a/website/content/portfolio/rmv/_index.md +++ /dev/null @@ -1,10 +0,0 @@ -``` -usage: rmv [-h] -``` - -Removes an item from the portfolio - -``` -optional arguments: - -h, --help show this help message (default: False) -``` diff --git a/website/content/portfolio/rolling/_index.md b/website/content/portfolio/rolling/_index.md new file mode 100644 index 00000000000..fb62ea1bbaa --- /dev/null +++ b/website/content/portfolio/rolling/_index.md @@ -0,0 +1,20 @@ +``` +usage: rolling [-b BENCHMARK] [-l LENGTH] [-r RF] [-h] [--export {png,jpg,pdf,svg}] +``` +Show rolling portfolio metrics vs benchmark. + +The plots shown are: cumulative returns vs benchmark, rolling volatility of portfolio and benchmark, +rolling sharpe ratio of portfolio and benchmark and the rolling beta with respect to the benchmark. + +``` +optional arguments: + -b BENCHMARK, --benchmark BENCHMARK + Choose a ticker to be the benchmark (default: SPY) + -l LENGTH, --length LENGTH + Length of rolling window (default: 60) + -r RF, --rf RF Set risk free rate for calculations. (default: 0.001) + -h, --help show this help message (default: False) + --export {png,jpg,pdf,svg} + Export figure into png, jpg, pdf, svg (default: ) + +``` \ No newline at end of file diff --git a/website/data/menu/main.yml b/website/data/menu/main.yml index 0839bd45d73..9b96f19d483 100755 --- a/website/data/menu/main.yml +++ b/website/data/menu/main.yml @@ -1122,14 +1122,18 @@ main: ref: "/portfolio/show" - name: add ref: "/portfolio/add" - - name: rmv - ref: "/portfolio/rmv" - name: ar ref: "/portfolio/ar" - name: rmr ref: "/portfolio/rmr" - name: mdd - ref: "portfolio/mdd" + ref: "portfolio/dd" + - name: build + ref: "portfolio/build" + - name: init + ref: "portfolio/init" + - name: rolling + ref: "portfolio/rolling" - name: forex ref: "/forex" sub: