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>
This commit is contained in:
jmaslek
2022-02-02 15:20:05 -05:00
committed by GitHub
parent 7bb378015c
commit 2a998a5a41
17 changed files with 922 additions and 767 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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
1 Name Type Quantity Date Price Fees Premium Side
2 cash cash 1 2021/7/20 30000 0 0 deposit
3 btc crypto 1 2021/7/20 29790 0 0 Buy

View File

@@ -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
1 Name Type Quantity Date Price Fees Premium Side
2 cash cash 1.0 2019/6/22 2021/3/24 2000.0 0 0 deposit
3 amzn aapl stock 300.0 10 2019/10/21 2021/3/24 2000.0 120.7 20.0 0 0 Buy
4 amzn msft stock 100.0 3 2019/10/21 2021/3/26 2100.0 232.8 20.0 0 0 Sell Buy
5 amc btc stock crypto 400.0 .006 2019/10/21 2021/5/21 20.0 37430 20.0 0 0 Buy
6 tsla aapl stock 10.0 5 2020/10/10 2021/7/2 200.0 139.8 20.0 0 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

View File

@@ -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
1 Name Type Quantity Date Price Fees Premium Side
2 cash cash 1.0 2021/3/24 2000.0 deposit
3 aapl stock 10 2021/3/24 120.7 0 0 Buy
4 msft stock 3 2021/3/26 232.8 0 0 Buy
5 aapl stock 5 2021/7/2 139.8 0 0 Sell

View File

@@ -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}")

View File

@@ -222,7 +222,7 @@ def test_call_func_expect_queue(expected_queue, func, queue):
(
"call_show",
[],
"portfolio_view.show_df",
"",
[],
dict(),
),

View File

@@ -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

View File

@@ -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: )
```

View File

@@ -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).

View File

@@ -0,0 +1,9 @@
```
usage: init [-h]
```
Re-initialize with empty portfolio.
```
optional arguments:
-h, --help show this help message (default: False)
```

View File

@@ -1,10 +0,0 @@
```
usage: rmv [-h]
```
Removes an item from the portfolio
```
optional arguments:
-h, --help show this help message (default: False)
```

View File

@@ -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: )
```

View File

@@ -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: