Internal Rate of Return (Unequal Timing) in Python

Setup

from bs4 import BeautifulSoup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

Define the Internal Rate of Return (with Unequal Timing) Functions

Taken from https://github.com/waynez/xirr/blob/master/financial.py.

import datetime
from scipy import optimize

def xnpv(rate,cashflows):
    chron_order = sorted(cashflows, key = lambda x: x[0])
    t0 = chron_order[0][0]
    return sum([cf/(1+rate)**((t-t0).days/365.0) for (t,cf) in chron_order])

def xirr(cashflows,guess=0.1):
    return optimize.newton(lambda r: xnpv(r,cashflows),guess)

Parse and Manipulate Data

Open File

The raw HTML data was taken from the Transactions page on the Fundrise website.

with open('data/fundrise_transactions_201010_1.html') as file:
    link_list_html = file.read()
soup = BeautifulSoup(link_list_html,
                     "html.parser")
transactions = soup.find_all('div',
                             {'class':'transaction-item__expandable-section'})
print(len(transactions))
46

Parse Text for Each Transaction

transaction_list = []
for t in transactions:
    text = (t
            .get_text())
    transaction_list.append(text)

Add Each Transaction’s Text into a DataFrame

All text for each transaction on one line with tabs and newlines.

transaction_text = pd.DataFrame(transaction_list)
print(transaction_text.shape)
transaction_text.head()
(46, 1)
0
0 \n\n\n\n\n\t\t\t\t\t\t\tIssued Date\n\t\t\t\t\...
1 \n\n\n\n\n\t\t\t\t\t\t\tIssued Date\n\t\t\t\t\...
2 \n\n\n\n\n\t\t\t\t\t\t\tIssued Date\n\t\t\t\t\...
3 \n\n\n\n\n\t\t\t\t\t\t\tIssued Date\n\t\t\t\t\...
4 \n\n\n\n\n\t\t\t\t\t\t\tIssued Date\n\t\t\t\t\...

Remove all headers from the text.

for header in ['Name','Advisory fee','Quantity','Price','Gross dividend',
               'Net dividend','Total','Amount','Discount','Bank account',
               'Effective date','Issued date','Effective date','Issued date']:
    transaction_text[0] = (transaction_text[0]
                           .str.replace(header,''))

Tokenize the text string based on one or more tabs and newlines.

parsed = pd.DataFrame(
    transaction_text[0].str.split(r'[\t\n]+',
                                  expand=True))
parsed = (parsed
          .drop(columns=[0])
          .rename(columns={1:'type'}))
parsed = parsed.replace('',np.nan)
parsed.head()
type 2 3 4 5 6 7 8 9 10 ... 71 72 73 74 75 76 77 78 79 80
0 Issued Date Oct 7, 2020 Income eREIT $88.46 $1.89 $86.57 Growth eREIT $38.34 $1.94 $36.40 ... None None None None None None None None None NaN
1 Issued Date Jul 8, 2020 Growth eREIT $8.17 $1.91 $6.26 Income eREIT $83.31 $1.86 $81.45 ... None None None None None None None None None NaN
2 Issued Date Apr 8, 2020 Income eREIT $106.12 $1.87 $104.25 Growth eREIT $56.21 $1.91 $54.30 ... None None None None None None None None None NaN
3 Issued Date Jan 12, 2020 Income eREIT $109.52 $1.89 $107.63 Growth eREIT $55.29 $1.76 $53.53 ... None None None None None None None None None NaN
4 Issued Date Oct 8, 2019 Growth eREIT $56.81 $1.73 $55.08 Income eREIT $89.43 $1.89 $87.54 ... None None None None None None None None None NaN

5 rows × 80 columns

parsed['type'] = (parsed['type']
                  .replace({'Order #'           : 'Purchase',
                            'Issued Date'       : 'Dividend',
                            'Redemption number' : 'Withdrawal'}))
print(parsed['type'].value_counts())
Purchase      25
Dividend      19
Withdrawal     2
Name: type, dtype: int64

Parse into individual transaction types.

d = parsed[parsed['type']=='Dividend'].reset_index(drop=True)
p = parsed[parsed['type']=='Purchase'].reset_index(drop=True)
w = parsed[parsed['type']=='Withdrawal'].reset_index(drop=True)

Dividends

Drop Gross Dividend and Advisory Fee columns from d.

for col in range(4,80,4):
    d = (d
         .drop(columns=[col,col+1]))
d.columns = range(d.shape[1])
d = d.T.dropna(how='all').T
d = d.rename(columns={0:'type',
                      1:'date'})

Stack to create normalized data.

d_stack = pd.DataFrame()
for col in range(2,34,2):
    d_stack = (d_stack
               .append(d[['type','date',col,col+1]]
                       .rename(columns={col:'fund',
                                        col+1:'amount'})))
d_stack['date'] = pd.to_datetime(d_stack['date'])
d_stack = (d_stack
           .dropna(subset=['amount'])
           .drop_duplicates()
           .sort_values(['date','fund'])
           .reset_index(drop=True))

Purchases

p.columns = range(p.shape[1])
p = (p
     .drop(columns=[1,2,3,4,5]))
for col in range(8,80,4):
    p = p.drop(columns=[col,col+1])
p.columns = range(p.shape[1])
p = p.T.dropna(how='all').T
p = p.rename(columns={0:'type',
                      1:'date'})

Stack to create normalized data.

p_stack = pd.DataFrame()
for col in range(2,38,2):
    p_stack = (p_stack
               .append(p[['type','date',col,col+1]]
                       .rename(columns={col:'fund',
                                        col+1:'amount'})))
p_stack['date'] = pd.to_datetime(p_stack['date'])
p_stack = (p_stack
           .dropna(subset=['amount'])
           .drop_duplicates()
           .sort_values(['date','fund'])
           .reset_index(drop=True))
p_stack.head()
type date fund amount
0 Purchase 2016-03-02 Income eREIT $1,000.00
1 Purchase 2016-04-07 Growth eREIT $1,000.00
2 Purchase 2016-04-22 Income eREIT $1,000.00
3 Purchase 2016-09-28 Growth eREIT $1,000.00
4 Purchase 2016-09-29 Growth eREIT $1,000.00

Withdrawals

w.columns = range(w.shape[1])
w = (w
     .drop(columns=[1,2,4,6,7,8,10,11,12,13,14])
     .rename(columns={0:'type',
                      3:'date',
                      5:'fund',
                      9:'amount'}))
w = w.T.dropna(how='all').T
w_stack = w.copy()
w_stack['date'] = pd.to_datetime(w_stack['date'])
w_stack.head()
type date fund amount
0 Withdrawal 2018-04-29 Los Angeles eFund $2,425.00
1 Withdrawal 2018-04-29 Washington DC eFund $2,425.00

Current Value

From Estimated Value of Shares on 10/10/2020

c = pd.DataFrame([('Growth eREIT',        '$5,332.65'),
                  ('Income eREIT',        '$5,332.65'),
                  ('East Coast eREIT',    '$4,004.94'),
                  ('West Coast eREIT',    '$3,017.11'),
                  ('Heartland eREIT',     '$2,968.83'),
                  ('Income eREIT II',     '$1,029.73'),
                  ('Los Angeles eFund',   '$571.24'),
                  ('Washington DC eFund', '$531.79'),
                  ('Growth eREIT III',    '$264.39'),
                  ('Income eREIT III',    '$206.83'),
                  ('National eFund',      '$108.29'),
                  ('Fundrise iPO',        '$3,190.00')],
                  columns=['fund','amount'])
c['type'] = 'Current Value'
c['date'] = pd.to_datetime('2020-10-10')
c.head()
fund amount type date
0 Growth eREIT $5,332.65 Current Value 2020-10-10
1 Income eREIT $5,332.65 Current Value 2020-10-10
2 East Coast eREIT $4,004.94 Current Value 2020-10-10
3 West Coast eREIT $3,017.11 Current Value 2020-10-10
4 Heartland eREIT $2,968.83 Current Value 2020-10-10

Combine in to Single Transactions Dataset

t = (d_stack
     .append(p_stack)
     .append(w_stack)
     .append(c)
     .sort_values(['date','fund'])
     .reset_index(drop=True))
t['amount'] = (t['amount']
               .str.replace('$','')
               .str.replace(',','')
               .astype(float))
t.loc[t['type']=='Purchase','amount'] = -1*t['amount']

Write to file for quick reference later.

t.to_csv('cleansed_transactions.csv')
t[t['fund']=='Income eREIT'].tail()
type date fund amount
134 Dividend 2020-01-12 Income eREIT 107.63
142 Dividend 2020-04-08 Income eREIT 104.25
149 Dividend 2020-07-08 Income eREIT 81.45
157 Dividend 2020-10-07 Income eREIT 86.57
166 Current Value 2020-10-10 Income eREIT 5332.65

Calculate Internal Rate of Return of Funds

returns = []
for fund in t['fund'].unique():
    cashflows = t[t['fund']==fund][['date','amount']].to_numpy()
    r = round(xirr(cashflows,guess=0.1)*100,1)
    returns.append((fund,r))
returns = (pd.DataFrame(returns,
                        columns=['fund','irr'])
           .sort_values('irr',
                        ascending=False)
           .reset_index(drop=True))
current_value = (t[t['type']=='Current Value'][['fund','amount']]
                 .rename(columns={'amount':'current value'}))
returns = pd.merge(returns, current_value,
                   on=['fund'],
                   how='outer')
cost_basis = (t[t['type']=='Purchase'][['fund','amount']]
              .rename(columns={'amount':'cost basis'})
              .groupby('fund')
              .sum()
              .reset_index())
cost_basis['cost basis'] *= -1
dividends = (t[t['type']=='Dividend'][['fund','amount']]
             .rename(columns={'amount':'dividends'})
             .groupby('fund')
             .sum()
             .reset_index())
withdrawals = (t[t['type']=='Withdrawal'][['fund','amount']]
               .rename(columns={'amount':'withdrawals'})
               .groupby('fund')
               .sum()
               .reset_index())
first_purchase = (t[t['type']=='Purchase'][['fund','date']]
                  .rename(columns={'date':'first purchase'})
                  .groupby('fund')
                  .min()
                  .reset_index())
last_purchase = (t[t['type']=='Purchase'][['fund','date']]
                 .rename(columns={'date':'last purchase'})
                 .groupby('fund')
                 .max()
                 .reset_index())
returns = pd.merge(returns, cost_basis,
                   on=['fund'],
                   how='outer')
returns = pd.merge(returns, withdrawals,
                   on=['fund'],
                   how='outer')
returns['appreciation'] = returns['cost basis'] - returns['withdrawals'].fillna(0)
returns['appreciation'] = returns['current value'] - returns['appreciation']
returns['appreciation'] = returns['appreciation'].round(2)
returns = pd.merge(returns, dividends,
                   on=['fund'],
                   how='outer')
for col in ['current value', 'cost basis', 'withdrawals', 'appreciation', 'dividends']:
    returns[col] = returns[col].round(0)
returns = returns.fillna('')
for col in ['current value', 'cost basis', 'appreciation']:
    returns[col] = returns[col].astype(int)
returns = pd.merge(returns, first_purchase,
                   on=['fund'],
                   how='outer')
returns = pd.merge(returns, last_purchase,
                   on=['fund'],
                   how='outer').fillna('')
returns
fund irr current value cost basis withdrawals appreciation dividends first purchase last purchase
0 Growth eREIT 13.1 5333 4001 1331 891 2016-04-07 2019-06-18
1 East Coast eREIT 11.5 4005 3470 535 744 2016-10-24 2019-06-18
2 Income eREIT 10.2 5333 5014 318 1596 2016-03-02 2019-04-25
3 Income eREIT III 9.7 207 205 2 24 2019-04-22 2019-06-27
4 Income eREIT II 8.0 1030 1028 2 116 2019-04-17 2019-04-25
5 West Coast eREIT 6.7 3017 3017 0 670 2016-10-25 2019-06-18
6 Heartland eREIT 5.4 2969 3017 -48 582 2016-10-25 2019-06-18
7 Growth eREIT III 5.2 264 253 11 8 2019-04-22 2019-06-18
8 National eFund 3.2 108 104 4 2019-06-14 2019-06-27
9 Fundrise iPO -0.0 3190 3190 0 2017-02-10 2019-03-25
10 Los Angeles eFund -0.8 571 3024 2425 -28 2017-08-18 2019-06-18
11 Washington DC eFund -2.0 532 3024 2425 -67 2017-08-18 2019-06-18

Decision

cashflows = t[['date','amount']].to_numpy()
r = round(xirr(cashflows,guess=0.1)*100,1)
print('All Funds IRR to Date: {}%'.format(r))
All Funds IRR to Date: 8.1%
irr_threshold_to_liquidate = 7
plt.figure(figsize=(11,8.5))
barlist = plt.bar(returns['fund'],
                  returns['irr'])
plt.xticks(rotation=20)
for spine in plt.gca().spines.values():
    spine.set_visible(False)
for bar in barlist[5:]:
    bar.set_color('#ff7f0e')
plt.axhline(irr_threshold_to_liquidate,
            color='k',
            linewidth=1)
plt.title('Keep the Funds in Blue');

png

funds_to_retain = returns[(returns['irr']>irr_threshold_to_liquidate)&
                          (returns['fund']!='Fundrise iPO')].copy()
print('Total Amount to Retain: $ {}'.format(funds_to_retain['current value'].sum()))
print('          Fundrise iPO: $  {}'.format((returns[returns['fund']=='Fundrise iPO']
                                                   ['current value'].to_numpy()[0])))
cashflows = t[t['fund'].isin(funds_to_retain['fund'])][['date','amount']].to_numpy()
r = round(xirr(cashflows,guess=0.1)*100,1)
print('    Retained Funds IRR:      {}%'.format(r))
funds_to_retain
Total Amount to Retain: $ 15908
          Fundrise iPO: $  3190
    Retained Funds IRR:      11.4%
fund irr current value cost basis withdrawals appreciation dividends first purchase last purchase
0 Growth eREIT 13.1 5333 4001 1331 891 2016-04-07 2019-06-18
1 East Coast eREIT 11.5 4005 3470 535 744 2016-10-24 2019-06-18
2 Income eREIT 10.2 5333 5014 318 1596 2016-03-02 2019-04-25
3 Income eREIT III 9.7 207 205 2 24 2019-04-22 2019-06-27
4 Income eREIT II 8.0 1030 1028 2 116 2019-04-17 2019-04-25
funds_to_liquidate = returns[(returns['irr']<irr_threshold_to_liquidate)&
                             (returns['fund']!='Fundrise iPO')].copy()
print('Total Amount to Liqudate: $ {}'.format((funds_to_liquidate['current value']
                                               .sum()
                                               .round(2))))
cashflows = t[t['fund'].isin(funds_to_liquidate['fund'])][['date','amount']].to_numpy()
r = round(xirr(cashflows,guess=0.1)*100,1)
print('    Liquidated Funds IRR:      {}%'.format(r))
funds_to_liquidate
Total Amount to Liqudate: $ 7461
    Liquidated Funds IRR:      4.1%
fund irr current value cost basis withdrawals appreciation dividends first purchase last purchase
5 West Coast eREIT 6.7 3017 3017 0 670 2016-10-25 2019-06-18
6 Heartland eREIT 5.4 2969 3017 -48 582 2016-10-25 2019-06-18
7 Growth eREIT III 5.2 264 253 11 8 2019-04-22 2019-06-18
8 National eFund 3.2 108 104 4 2019-06-14 2019-06-27
10 Los Angeles eFund -0.8 571 3024 2425 -28 2017-08-18 2019-06-18
11 Washington DC eFund -2.0 532 3024 2425 -67 2017-08-18 2019-06-18