Files
OpenBB/openbb_terminal/portfolio/portfolio_engine.py
DidierRLopes c40c3f7529 Creates a "--dev" flag that points to dev backend for testing with hub (#5319)
* check that routine title is valid

* more updates

* small fixes

* override local routine

* more improvements to routine logic

* --debug runs .dev

* few more updates

* small fix

* rename BASE_URL from ultima to ULTIMA_BASE_URL

* use debug from dataclasses instead of env since env doesn't get updated

* create --dev flag that can point to .dev backend

* merge conflits

* few changes, made it actually work 🤣

---------

Co-authored-by: tehcoderer <me@tehcoderer.com>
2023-08-13 23:20:07 -07:00

1005 lines
38 KiB
Python

"""Portfolio Engine"""
__docformat__ = "numpy"
import datetime
import logging
import warnings
from os import environ
from typing import Any, Dict
import numpy as np
import pandas as pd
import yfinance as yf
from tqdm import tqdm
from openbb_terminal.decorators import log_start_end
from openbb_terminal.portfolio.allocation_model import get_allocation
from openbb_terminal.portfolio.portfolio_helper import (
get_info_from_ticker,
make_equal_length,
)
from openbb_terminal.rich_config import console
from openbb_terminal.stocks.fundamental_analysis.yahoo_finance_model import get_splits
from openbb_terminal.terminal_helper import suppress_stdout
from openbb_terminal.core.session.current_system import get_current_system
# pylint: disable=E1136,W0201,R0902,C0302
# pylint: disable=unsupported-assignment-operation,redefined-outer-name
# pylint: too-many-public-methods, consider-using-f-string,disable=raise-missing-from
logger = logging.getLogger(__name__)
pd.options.mode.chained_assignment = None
class PortfolioEngine:
"""Class for portfolio analysis in OpenBB
Implements a Portfolio and related methods.
Attributes
----------
# General
empty: bool
True if no transactions have been loaded
risk_free_rate: float
Risk free rate
inception_date: datetime.date
Inception date of the portfolio
tickers_list: list
List of tickers in the portfolio
tickers: Dict[Any, Any]
Dictionary of ticker type (e.g. ETF, STOCK, CRYPTO) as keys and ticker
list as values
benchmark_ticker: str
Benchmark ticker
benchmark_info: Any
Benchmark info
historical_trade_data: pd.DataFrame
DataFrame with state of the portfolio at each day since inception, including
trade quantity, invested amount, return, etc.
# Portfolio
portfolio_historical_prices: pd.DataFrame
DataFrame with historical prices of the ticker in the portfolio
portfolio_returns: pd.DataFrame
DataFrame with portfolio returns, i.e. the total daily return of the portfolio
portfolio_trades: pd.DataFrame
DataFrame with portfolio trades and respective performance
portfolio_assets_allocation: pd.DataFrame
DataFrame with portfolio assets allocation
portfolio_sectors_allocation: pd.DataFrame
DataFrame with portfolio sectors allocation
portfolio_regions_allocation: pd.DataFrame
DataFrame with portfolio regions allocation
portfolio_countries_allocation: pd.DataFrame
DataFrame with portfolio countries allocation
# Benchmark
benchmark_historical_prices: pd.DataFrame
DataFrame with historical prices of the benchmark ticker
benchmark_returns: pd.DataFrame
DataFrame with benchmark returns, i.e. the total daily return of the benchmark
benchmark_trades: pd.DataFrame
DataFrame with benchmark trades and respective performance
benchmark_assets_allocation: pd.DataFrame
DataFrame with benchmark assets allocation
benchmark_sectors_allocation: pd.DataFrame
DataFrame with benchmark sectors allocation
benchmark_regions_allocation: pd.DataFrame
DataFrame with benchmark regions allocation
benchmark_countries_allocation: pd.DataFrame
DataFrame with benchmark countries allocation
Methods
-------
read_transactions: Static method to read transactions from file
__set_transactions:
__preprocess_transactions: Method to preprocess, format and compute auxiliary fields
__load_company_data: Load company data for stocks such as sector, industry and country
get_transactions: Outputs the formatted transactions DataFrame
set_benchmark: Adds benchmark ticker, info, prices and returns
__mimic_trades_for_benchmark: Mimic trades from the transactions based on chosen benchmark assuming partial shares
generate_portfolio_data: Generates portfolio data from transactions
__load_portfolio_historical_prices: Loads historical adj close prices for tickers in list of trades
__populate_historical_trade_data: Create a new dataframe to store historical prices by ticker
__calculate_portfolio_returns: Calculate portfolio daily returns
__calculate_portfolio_performance: Calculate portfolio trades performance
set_risk_free_rate: Sets risk free rate
calculate_reserves: Takes dividends into account for returns calculation
calculate_allocation: Determine allocation based on assets, sectors, countries and regions.
"""
def __init__(self, transactions: pd.DataFrame = pd.DataFrame()):
"""Initialize PortfolioEngine class"""
# General
self.empty = True
self.risk_free_rate = float(0)
self.inception_date = datetime.date(1970, 1, 1)
self.tickers_list = None
self.tickers: Dict[Any, Any] = {}
self.benchmark_ticker: str = ""
self.historical_trade_data = pd.DataFrame()
# Portfolio
self.portfolio_historical_prices = pd.DataFrame()
self.portfolio_returns = pd.DataFrame()
self.portfolio_trades = pd.DataFrame()
self.portfolio_assets_allocation = pd.DataFrame()
self.portfolio_sectors_allocation = pd.DataFrame()
self.portfolio_regions_allocation = pd.DataFrame()
self.portfolio_countries_allocation = pd.DataFrame()
# Benchmark
self.benchmark_historical_prices = pd.DataFrame()
self.benchmark_returns = pd.DataFrame()
self.benchmark_trades = pd.DataFrame()
self.benchmark_assets_allocation = pd.DataFrame()
self.benchmark_sectors_allocation = pd.DataFrame()
self.benchmark_regions_allocation = pd.DataFrame()
self.benchmark_countries_allocation = pd.DataFrame()
# Set and preprocess transactions
if not transactions.empty:
self.__set_transactions(transactions)
def __set_transactions(self, transactions):
self.__transactions = transactions
self.__preprocess_transactions()
self.empty = False
def get_transactions(self):
"""Get formatted transactions
Returns
-------
pd.DataFrame
Formatted transactions
"""
df = self.__transactions[
[
"Date",
"Type",
"Ticker",
"Side",
"Price",
"Quantity",
"Fees",
"Investment",
"Currency",
"Sector",
"Industry",
"Country",
"Region",
]
]
df = df.replace(np.nan, "-")
df["Date"] = df["Date"].dt.strftime("%Y-%m-%d")
df.sort_values(by="Date", ascending=False, inplace=True)
return df
@staticmethod
def read_transactions(path: str) -> pd.DataFrame:
"""Read static method to read transactions from file.
Parameters
----------
path: str
path to transactions file
Returns
-------
pd.DataFrame
DataFrame with transactions
"""
# Load transactions from file
if path.endswith(".xlsx"):
if not get_current_system().DEBUG_MODE:
warnings.filterwarnings(
"ignore", category=UserWarning, module="openpyxl"
)
transactions = pd.read_excel(path)
elif path.endswith(".csv"):
transactions = pd.read_csv(path)
return transactions
@log_start_end(log=logger)
def __preprocess_transactions(self):
"""Preprocess, format and compute auxiliary fields.
Preprocessing steps:
0. If optional fields not in the transactions add missing
1. Convert Date to datetime
2. Sort transactions by date
3. Capitalize Ticker and Type [of instrument...]
4. Translate side: ["deposit", "buy"] -> 1 and ["withdrawal", "sell"] -> -1
5. Convert quantity to signed integer
6. Determining the investment/divestment value
7. Reformat crypto tickers to yfinance format (e.g. BTC -> BTC-USD)
8. Reformat STOCK/ETF tickers to yfinance format if ISIN provided
9. Remove unsupported ISINs that came out empty
10. Create tickers dictionary with structure {'Type': [Ticker]}
11. Create list with tickers except cash
12. Save transactions inception date
13. Populate fields Sector, Industry and Country
"""
p_bar = tqdm(range(14), desc="Preprocessing transactions", leave=False)
try:
# 0. If optional fields not in the transactions add missing
optional_fields = [
"Sector",
"Industry",
"Country",
"Region",
"Fees",
"Premium",
"ISIN",
]
if not set(optional_fields).issubset(set(self.__transactions.columns)):
for field in optional_fields:
if field not in self.__transactions.columns:
self.__transactions[field] = np.nan
p_bar.n += 1
p_bar.refresh()
# 1. Convert Date to datetime
self.__transactions["Date"] = pd.to_datetime(self.__transactions["Date"])
p_bar.n += 1
p_bar.refresh()
# 2. Sort transactions by date
self.__transactions = self.__transactions.sort_values(by="Date")
p_bar.n += 1
p_bar.refresh()
# 3. Capitalize Ticker and Type [of instrument...]
self.__transactions["Ticker"] = self.__transactions["Ticker"].map(
lambda x: x.upper() if isinstance(x, str) else x
)
self.__transactions["Type"] = self.__transactions["Type"].map(
lambda x: x.upper() if isinstance(x, str) else x
)
p_bar.n += 1
p_bar.refresh()
# 4. Translate side: ["deposit", "buy"] -> 1 and ["withdrawal", "sell"] -> -1
self.__transactions["Signal"] = self.__transactions["Side"].map(
lambda x: 1
if x.lower() in ["deposit", "buy"]
else (-1 if x.lower() in ["withdrawal", "sell"] else 0)
)
p_bar.n += 1
p_bar.refresh()
# 5. Convert quantity to signed integer
self.__transactions["Quantity"] = (
abs(self.__transactions["Quantity"]) * self.__transactions["Signal"]
)
# Adjust quantity and price for splits
for ticker in self.__transactions["Ticker"].unique():
try:
splits_df = get_splits(ticker)
if not splits_df.empty:
splits_df = splits_df.tz_localize(tz=None)
for split_date in splits_df.index:
self.__transactions["Quantity"] = np.where(
(self.__transactions["Ticker"] == ticker)
& (self.__transactions["Date"] < split_date),
self.__transactions["Quantity"]
* splits_df.loc[split_date].values,
self.__transactions["Quantity"],
)
self.__transactions["Price"] = np.where(
(self.__transactions["Ticker"] == ticker)
& (self.__transactions["Date"] < split_date),
self.__transactions["Price"]
/ splits_df.loc[split_date].values,
self.__transactions["Price"],
)
except Exception:
console.print("\nCould not get splits adjusted")
p_bar.n += 1
p_bar.refresh()
# 6. Determining the investment/divestment value
self.__transactions["Investment"] = (
self.__transactions["Quantity"] * self.__transactions["Price"]
+ self.__transactions["Fees"]
)
p_bar.n += 1
p_bar.refresh()
# 7. Reformat crypto tickers to yfinance format (e.g. BTC -> BTC-USD)
crypto_trades = self.__transactions[self.__transactions.Type == "CRYPTO"]
self.__transactions.loc[
(self.__transactions.Type == "CRYPTO"), "Ticker"
] = [
f"{crypto}-{currency}"
for crypto, currency in zip(
crypto_trades.Ticker, crypto_trades.Currency
)
]
p_bar.n += 1
p_bar.refresh()
# 8. Reformat STOCK/ETF tickers to yfinance format if ISIN provided.
# If isin not valid ticker is empty
self.__transactions["yf_Ticker"] = self.__transactions["ISIN"].apply(
lambda x: yf.utils.get_ticker_by_isin(x) if not pd.isna(x) else np.nan
)
empty_tickers = list(
self.__transactions[
(self.__transactions["yf_Ticker"] == "")
| (self.__transactions["yf_Ticker"].isna())
]["Ticker"].unique()
)
# If ticker from isin is empty it is not valid in yfinance, so check if user provided ticker is supported
removed_tickers = []
for item in empty_tickers:
with suppress_stdout():
# Suppress yfinance failed download message if occurs
valid_ticker = not (
yf.download(
item,
start=datetime.datetime.now() + datetime.timedelta(days=-5),
progress=False,
).empty
)
if valid_ticker:
# Invalid ISIN but valid ticker
self.__transactions.loc[
self.__transactions["Ticker"] == item, "yf_Ticker"
] = np.nan
else:
self.__transactions.loc[
self.__transactions["Ticker"] == item, "yf_Ticker"
] = ""
removed_tickers.append(item)
# Merge reformatted tickers into Ticker
self.__transactions["Ticker"] = self.__transactions["yf_Ticker"].fillna(
self.__transactions["Ticker"]
)
p_bar.n += 1
p_bar.refresh()
# 9. Remove unsupported ISINs that came out empty
self.__transactions.drop(
self.__transactions[self.__transactions["Ticker"] == ""].index,
inplace=True,
)
p_bar.n += 1
p_bar.refresh()
# 10. Create tickers dictionary with structure {'Type': [Ticker]}
unsupported_type = self.__transactions[
(~self.__transactions["Type"].isin(["STOCK", "ETF", "CRYPTO"]))
].index
if unsupported_type.any():
self.__transactions.drop(unsupported_type, inplace=True)
console.print(
"[red]Unsupported transaction type detected and removed. Supported types: stock, etf or crypto.[/red]"
)
for ticker_type in set(self.__transactions["Type"]):
self.tickers[ticker_type] = list(
set(
self.__transactions[
self.__transactions["Type"].isin([ticker_type])
]["Ticker"]
)
)
p_bar.n += 1
p_bar.refresh()
# 11. Create list with tickers except cash
self.tickers_list = list(set(self.__transactions["Ticker"]))
p_bar.n += 1
p_bar.refresh()
# 12. Save transactions inception date
self.inception_date = self.__transactions["Date"].iloc[0]
p_bar.n += 1
p_bar.refresh()
# 13. Populate fields Sector, Industry and Country
if (
self.__transactions.loc[
self.__transactions["Type"] == "STOCK",
optional_fields,
]
.isnull()
.values.any()
):
# If any fields is empty for stocks (overwrites any info there)
self.__load_company_data()
p_bar.n += 1
p_bar.refresh()
# Warn user of removed ISINs
if removed_tickers:
p_bar.disable = True
console.print(
f"\n[red]The following tickers are not supported and were removed: {removed_tickers}."
f"\nManually edit the 'Ticker' field with the proper Yahoo Finance suffix or provide a valid ISIN."
f"\nSuffix info on 'Yahoo Finance market coverage':"
" https://help.yahoo.com/kb/exchanges-data-providers-yahoo-finance-sln2310.html"
f"\nE.g. IWDA -> IWDA.AS[/red]\n"
)
except Exception:
console.print("\nCould not preprocess transactions.")
raise
@log_start_end(log=logger)
def __load_company_data(self):
"""Load company data for stocks such as sector, industry and country"""
for ticker_type, ticker_list in self.tickers.items():
# yfinance only has sector, industry and country for stocks
if ticker_type == "STOCK":
for ticker in ticker_list:
# Only gets fields for tickers with missing data
# TODO: Should only get field missing for tickers with missing data
# now it's taking the 4 of them
if (
self.__transactions.loc[
self.__transactions["Ticker"] == ticker,
["Sector", "Industry", "Country", "Region"],
]
.isnull()
.values.any()
):
# Get ticker info in list ["Sector", "Industry", "Country", "Region"] from isin/ticker
info_list = get_info_from_ticker(ticker)
# Replace fields in transactions
self.__transactions.loc[
self.__transactions.Ticker == ticker,
["Sector", "Industry", "Country", "Region"],
] = info_list
elif ticker_type == "CRYPTO":
for ticker in ticker_list:
if (
self.__transactions.loc[
self.__transactions["Ticker"] == ticker,
["Sector", "Industry", "Country", "Region"],
]
.isnull()
.values.any()
):
# Get ticker info in list ["Sector", "Industry", "Country", "Region"]
info_list = ["Crypto", "Crypto", "Crypto", "Crypto"]
# Replace fields in transactions
self.__transactions.loc[
self.__transactions.Ticker == ticker,
["Sector", "Industry", "Country", "Region"],
] = info_list
else:
for ticker in ticker_list:
if (
self.__transactions.loc[
self.__transactions["Ticker"] == ticker,
["Sector", "Industry", "Country", "Region"],
]
.isnull()
.values.any()
):
# Get ticker info in list ["Sector", "Industry", "Country", "Region"]
info_list = ["-", "-", "-", "-"]
# Replace fields in transactions
self.__transactions.loc[
self.__transactions.Ticker == ticker,
["Sector", "Industry", "Country", "Region"],
] = info_list
@log_start_end(log=logger)
def set_benchmark(self, symbol: str = "SPY", full_shares: bool = False) -> bool:
"""Load benchmark into portfolio.
Parameters
----------
symbol: str
Benchmark symbol to download data
full_shares: bool
Whether to mimic the portfolio trades exactly (partial shares) or round down the
quantity to the nearest number
Returns
-------
bool
True if successful, False otherwise
"""
p_bar = tqdm(range(4), desc=" Loading benchmark", leave=False)
self.benchmark_historical_prices = yf.download(
symbol,
start=self.inception_date - datetime.timedelta(days=1),
threads=False,
progress=False,
ignore_tz=True,
)["Adj Close"]
if self.benchmark_historical_prices.empty:
console.print(
f"\n[red]Could not download benchmark data for {symbol}."
" Choose another symbol.\n[/red]"
)
return False
self.benchmark_ticker = symbol
p_bar.n += 1
p_bar.refresh()
self.__mimic_trades_for_benchmark(full_shares)
# Merge benchmark and portfolio dates to ensure same length
self.benchmark_historical_prices = pd.merge(
self.portfolio_historical_prices["Close"],
self.benchmark_historical_prices,
how="outer",
left_index=True,
right_index=True,
)["Adj Close"]
self.benchmark_historical_prices.fillna(method="ffill", inplace=True)
p_bar.n += 1
p_bar.refresh()
self.benchmark_returns = self.benchmark_historical_prices.pct_change().dropna()
(
self.portfolio_returns,
self.benchmark_returns,
) = make_equal_length(self.portfolio_returns, self.benchmark_returns)
p_bar.n += 1
p_bar.refresh()
return True
@log_start_end(log=logger)
def __mimic_trades_for_benchmark(self, full_shares: bool = False):
"""Mimic trades from the transactions based on chosen benchmark assuming partial shares
Parameters
----------
full_shares: bool
whether to mimic the portfolio trades exactly (partial shares) or round down the
quantity to the nearest number.
"""
# Create dataframe to store benchmark trades
self.benchmark_trades = self.__transactions[
["Date", "Type", "Investment"]
].copy()
# Set current price of benchmark
self.benchmark_trades["Last price"] = self.benchmark_historical_prices[-1]
# Map historical prices into trades
self.benchmark_trades[["Benchmark Quantity"]] = float(0)
benchmark_historical_prices = pd.DataFrame(self.benchmark_historical_prices)
benchmark_historical_prices.columns.values[0] = "Trade price"
self.benchmark_trades = self.benchmark_trades.set_index("Date")
self.benchmark_trades.index = pd.to_datetime(self.benchmark_trades.index)
self.benchmark_trades = self.benchmark_trades.merge(
benchmark_historical_prices, how="left", left_index=True, right_index=True
)
self.benchmark_trades = self.benchmark_trades.reset_index()
self.benchmark_trades["Trade price"] = self.benchmark_trades[
"Trade price"
].fillna(method="ffill")
# Calculate benchmark investment quantity
if full_shares:
self.benchmark_trades["Benchmark Quantity"] = np.floor(
self.benchmark_trades["Investment"]
/ self.benchmark_trades["Trade price"]
)
else:
self.benchmark_trades["Benchmark Quantity"] = (
self.benchmark_trades["Investment"]
/ self.benchmark_trades["Trade price"]
)
self.benchmark_trades["Benchmark Investment"] = (
self.benchmark_trades["Trade price"]
* self.benchmark_trades["Benchmark Quantity"]
)
self.benchmark_trades["Benchmark Value"] = (
self.benchmark_trades["Last price"]
* self.benchmark_trades["Benchmark Quantity"]
)
self.benchmark_trades["Benchmark % Return"] = (
self.benchmark_trades["Benchmark Value"]
/ self.benchmark_trades["Benchmark Investment"]
- 1
)
self.benchmark_trades["Benchmark Abs Return"] = (
self.benchmark_trades["Benchmark Value"]
- self.benchmark_trades["Benchmark Investment"]
)
# TODO: To add alpha here, we must pull prices from original trades and get last price
# for each of those trades. Then just calculate returns and compare to benchmark
self.benchmark_trades.fillna(0, inplace=True)
if full_shares:
console.print(
"Note that with full shares (-s) enabled, there will be a mismatch between how much you invested in the"
f" portfolio ({round(sum(self.portfolio_trades['Portfolio Investment']), 2)}) and how much you invested"
f" in the benchmark ({round(sum(self.benchmark_trades['Benchmark Investment']), 2)})."
)
@log_start_end(log=logger)
def generate_portfolio_data(self):
"""Generate portfolio data from transactions
Workflow:
1. Load historical adj close/close prices for tickers in list of trades
2. Record the state of the portfolio at each day since inception.
3. Calculate portfolio daily returns from historical trade data
4. Calculate portfolio trades performance
"""
self.__load_portfolio_historical_prices()
self.__populate_historical_trade_data()
self.__calculate_portfolio_returns()
self.__calculate_portfolio_performance()
@log_start_end(log=logger)
def __load_portfolio_historical_prices(self, use_close: bool = False):
"""Load historical adj close/close prices for tickers in list of trades
Parameters
----------
use_close: bool
whether to use close or adjusted close prices
"""
p_bar = tqdm(
range(len(self.tickers)), desc=" Loading price data", leave=False
)
for ticker_type, data in self.tickers.items():
price_data = yf.download(
data, start=self.inception_date, progress=False, ignore_tz=True
)["Close" if use_close or ticker_type == "CRYPTO" else "Adj Close"]
# Set up column name if only 1 ticker (pd.DataFrame only does this if >1 ticker)
if len(data) == 1:
price_data = pd.DataFrame(price_data)
price_data.columns = data
self.portfolio_historical_prices = pd.concat(
[self.portfolio_historical_prices, price_data], axis=1
)
self.portfolio_historical_prices.fillna(method="ffill", inplace=True)
p_bar.n += 1
p_bar.refresh()
@log_start_end(log=logger)
def __populate_historical_trade_data(self):
"""Record the state of the portfolio at each day since inception
The state includes the following information for each ticker and total:
- Quantity: Number of shares held
- Investment: Total investment in shares
- Investment delta: Change in investment since last day
- Close: Close price of shares
- End value: Total value of shares at close price
"""
trade_data = self.__transactions.pivot_table(
index="Date",
columns=["Ticker"],
values=[
"Quantity",
"Investment",
],
aggfunc={"Quantity": np.sum, "Investment": np.sum},
)
# Make historical prices columns a multi-index. This helps the merging.
self.portfolio_historical_prices.columns = pd.MultiIndex.from_product(
[["Close"], self.portfolio_historical_prices.columns]
)
trade_data = pd.merge(
trade_data,
self.portfolio_historical_prices,
how="outer",
left_index=True,
right_index=True,
)
trade_data["Close"] = trade_data["Close"].fillna(method="ffill")
trade_data.fillna(0, inplace=True)
trade_data["Quantity"] = trade_data["Quantity"].cumsum()
trade_data["Investment"] = trade_data["Investment"].cumsum()
trade_data["Investment", "Total"] = trade_data["Investment"].sum(axis=1)
trade_data[
pd.MultiIndex.from_product(
[["Investment delta"], self.tickers_list + ["Total"]]
)
] = (trade_data["Investment"].diff(periods=1).fillna(trade_data["Investment"]))
# End Value = Quantity * Close
trade_data[pd.MultiIndex.from_product([["End Value"], self.tickers_list])] = (
trade_data["Quantity"][self.tickers_list]
* trade_data["Close"][self.tickers_list]
)
trade_data.loc[:, ("End Value", "Total")] = trade_data["End Value"][
self.tickers_list
].sum(axis=1)
# Initial Value = Previous End Value + Investment changes
trade_data[
pd.MultiIndex.from_product(
[["Initial Value"], self.tickers_list + ["Total"]]
)
] = 0
trade_data["Initial Value"] = trade_data["End Value"].shift(1) + trade_data[
"Investment"
].diff(periods=1)
# Set first day Initial Value as the Investment (NaNs break first period)
for t in self.tickers_list + ["Total"]:
trade_data.at[trade_data.index[0], ("Initial Value", t)] = trade_data.iloc[
0
]["Investment"][t]
trade_data = trade_data.reindex(
columns=[
"Quantity",
"Investment",
"Investment delta",
"Close",
"Initial Value",
"End Value",
],
level=0,
)
self.historical_trade_data = trade_data
@log_start_end(log=logger)
def __calculate_portfolio_returns(self):
"""Calculate portfolio daily returns from historical trade data
- End value: Total value of shares at close price
- Investment delta: Change in investment since last day
- Period cash inflow: min(0, Investment delta)
Any negative change in investment, should occur after a sale
- Period cash outflow: max(0, Investment delta)
Any positive change in investment, should occur after a purchase
- Period return: Value at end / Value at start - 1
Value at start: [End Value(t - 1) + Cash Outflow(t)]
We assume that at period 't' start, the portfolio value is equal to
the sum of the value of assets held [End Value(t - 1)] plus the value
of any purchases made during the period [Cash Outflow(t)]. Here, we
are implicitly assuming that you deposit the cash to buy the shares at
the start.
Value at end: [End Value(t) + Cash Inflow(t)]
We assume that at period 't' end, the portfolio value is equal to
the sum of the value of assets held [End Value(t)] plus the proceeds
from any sales during the period [Cash Inflow(t)].
"""
p_bar = tqdm(range(1), desc=" Calculating returns", leave=False)
trade_data = self.historical_trade_data
# Helper functions to calculate cash inflow and outflow
def f_min(x):
return x.apply(lambda x: min(x, 0))
def f_max(x):
return x.apply(lambda x: max(x, 0))
# Calculate cash inflow and outflow
trade_data[
pd.MultiIndex.from_product(
[["Period cash inflow"], self.tickers_list + ["Total"]]
)
] = -1 * trade_data["Investment delta"][:].apply(lambda x: f_min(x), axis=0)
trade_data[
pd.MultiIndex.from_product(
[["Period cash outflow"], self.tickers_list + ["Total"]]
)
] = trade_data["Investment delta"][:].apply(lambda x: f_max(x), axis=1)
# Calculate period return
trade_data[
pd.MultiIndex.from_product(
[["Period absolute return"], self.tickers_list + ["Total"]]
)
] = (trade_data["End Value"] + trade_data["Period cash inflow"]) - (
trade_data["End Value"].shift(1).fillna(0)
+ trade_data["Period cash outflow"]
)
trade_data[
pd.MultiIndex.from_product(
[["Period percentage return"], self.tickers_list + ["Total"]]
)
] = (trade_data["End Value"] + trade_data["Period cash inflow"]) / (
trade_data["End Value"].shift(1).fillna(0)
+ trade_data["Period cash outflow"]
) - 1
trade_data["Period percentage return"].fillna(0, inplace=True)
self.historical_trade_data = trade_data
self.portfolio_returns = self.historical_trade_data["Period percentage return"][
"Total"
]
p_bar.n += 1
p_bar.refresh()
@log_start_end(log=logger)
def __calculate_portfolio_performance(self):
"""Calculate portfolio performance"""
# TODO: saving the trade performance in portfolio_trades is wrong as it is.
# If we have sales we end up summing negative investments and positive investments.
# That leads to wrong % return calculation. This implementation does not allow
# performance between trade dates.
# We need to fix this.
# Determine invested amount, relative and absolute return based on last close
last_price = self.historical_trade_data["Close"].iloc[-1]
# Save portfolio trades to compute allocations later
self.portfolio_trades = self.__transactions.copy()
self.portfolio_trades[
[
"Portfolio Investment",
"Close",
"Portfolio Value",
"Portfolio % Return",
"Abs Portfolio Return",
]
] = float(0)
for index, trade in self.__transactions.iterrows():
self.portfolio_trades["Close"][index] = last_price[trade["Ticker"]]
self.portfolio_trades["Portfolio Investment"][index] = trade["Investment"]
self.portfolio_trades["Portfolio Value"][index] = (
self.portfolio_trades["Close"][index] * trade["Quantity"]
)
self.portfolio_trades["Portfolio % Return"][index] = (
self.portfolio_trades["Portfolio Value"][index]
/ self.portfolio_trades["Portfolio Investment"][index]
) - 1
self.portfolio_trades["Abs Portfolio Return"].loc[index] = (
self.portfolio_trades["Portfolio Value"][index]
- self.portfolio_trades["Portfolio Investment"][index]
)
@log_start_end(log=logger)
def set_risk_free_rate(self, risk_free_rate: float):
"""Set risk free rate
Parameters
----------
risk_free_rate : float
Risk free rate in float format
"""
self.risk_free_rate = risk_free_rate
@log_start_end(log=logger)
def calculate_reserves(self):
"""Take dividends into account for returns calculation"""
# TODO: Add back cash dividends and deduct exchange costs
console.print("Still has to be build.")
@log_start_end(log=logger)
def calculate_allocation(self, category: str, recalculate: bool = False):
"""Determine allocation based on Asset, Sector, Country or Region
Parameters
----------
category: str
Chosen allocation category from Asset, Sector, Country or Region
recalculate: bool
Flag to force recalculate allocation if already exists
"""
if not self.benchmark_ticker or self.portfolio_trades.empty:
console.print(
"Please load in a portfolio holdings first with `load --file` or obtain an example with `load --example`"
)
return
if category == "Asset":
if (
self.benchmark_assets_allocation.empty
or self.portfolio_assets_allocation.empty
or recalculate
):
(
self.benchmark_assets_allocation,
self.portfolio_assets_allocation,
) = get_allocation(
category, self.benchmark_ticker, self.portfolio_trades
)
elif category == "Sector":
if (
self.benchmark_sectors_allocation.empty
or self.portfolio_sectors_allocation.empty
or recalculate
):
(
self.benchmark_sectors_allocation,
self.portfolio_sectors_allocation,
) = get_allocation(
category, self.benchmark_ticker, self.portfolio_trades
)
elif category == "Country":
if (
self.benchmark_countries_allocation.empty
or self.portfolio_countries_allocation.empty
or recalculate
):
(
self.benchmark_countries_allocation,
self.portfolio_countries_allocation,
) = get_allocation(
category, self.benchmark_ticker, self.portfolio_trades
)
elif category == "Region":
if (
self.benchmark_regions_allocation.empty
or self.portfolio_regions_allocation.empty
or recalculate
):
(
self.benchmark_regions_allocation,
self.portfolio_regions_allocation,
) = get_allocation(
category, self.benchmark_ticker, self.portfolio_trades
)
else:
console.print(
"Category not available. Choose from: Asset, Sector, Country or Region"
)