mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 04:31:13 +00:00
* 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>
1005 lines
38 KiB
Python
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"
|
|
)
|