Skip to content

Commit 3fdbf71

Browse files
authored
Merge pull request #154 from DFIC-Quant-Fund/147-update-holdings-table-to-include-annualized-returns
Annualized Return Column in Portfolio Holdings Table
2 parents 4a5bd2d + e88188a commit 3fdbf71

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

src/models/portfolio_csv_builder.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,22 @@ def cumulative_split_factor(ticker, from_date, to_date):
900900
# If we sold, we add the cost basis of those shares to the "closed bucket"
901901
positions[ticker]['cost_of_closed'] += cost_chunk
902902

903+
# First purchase and last sale dates per ticker (for annualized return)
904+
first_purchase_dates = {}
905+
last_sale_dates = {}
906+
for date, row in sorted_trades.iterrows():
907+
t = row['Ticker']
908+
qty_trade = row['Quantity']
909+
if qty_trade > 0: # Buy: use first purchase date
910+
if t not in first_purchase_dates or date < first_purchase_dates[t]:
911+
first_purchase_dates[t] = date
912+
elif qty_trade < 0: # Sell: use last sale date
913+
if t not in last_sale_dates or date > last_sale_dates[t]:
914+
last_sale_dates[t] = date
915+
else:
916+
first_purchase_dates = {}
917+
last_sale_dates = {}
918+
903919
# 3. Process Dividends - No FX needed here!
904920
if self.dividend_income is not None and not self.dividend_income.empty:
905921
for date, row in self.dividend_income.iterrows():
@@ -948,6 +964,22 @@ def cumulative_split_factor(ticker, from_date, to_date):
948964
# Math is identical regardless of currency
949965
return_pct = (total_return_native / roi_denominator * 100.0) if roi_denominator > 0 else 0.0
950966

967+
# Annualized return since purchase: (1 + Total Return %)^(365 / Days Held) - 1
968+
# Days Held: first purchase -> today (open) or last sale (closed); use first purchase only
969+
is_open = data['qty'] > 0.00001
970+
first_purchase = first_purchase_dates.get(ticker)
971+
if first_purchase is None or roi_denominator <= 0:
972+
annualized_return_pct = float('nan')
973+
else:
974+
end_date = latest_date if is_open else last_sale_dates.get(ticker, first_purchase)
975+
start_d = pd.Timestamp(first_purchase)
976+
end_d = pd.Timestamp(end_date)
977+
days_held = (end_d - start_d).days
978+
if days_held > 0:
979+
annualized_return_pct = ((1.0 + return_pct / 100.0) ** (365.0 / days_held) - 1.0) * 100.0
980+
else:
981+
annualized_return_pct = float('nan')
982+
951983
# --- Aggregation Prep (Normalized to CAD) ---
952984
# We calculate a hidden CAD market value solely for the weighting step
953985
fx_multiplier = latest_fx_usd_cad if currency == 'USD' else 1.0
@@ -971,6 +1003,7 @@ def cumulative_split_factor(ticker, from_date, to_date):
9711003
'total_return': total_return_native,
9721004
'total_return_cad_normalized': total_return_native * latest_fx_usd_cad,
9731005
'total_return_pct': return_pct,
1006+
'annualized_return_pct': annualized_return_pct,
9741007

9751008
'mv_cad_normalized': market_val_cad_calc,
9761009
'invested_capital': roi_denominator,

src/views/holdings_table.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ def render_holdings_table(holdings_data: pd.DataFrame):
4040
'realized_pnl',
4141
'unrealized_pnl',
4242
'total_return',
43-
'total_return_pct',
43+
'total_return_pct',
44+
'annualized_return_pct',
4445
'sector',
4546
'asset_class',
4647
'status'
@@ -63,6 +64,7 @@ def render_holdings_table(holdings_data: pd.DataFrame):
6364
'unrealized_pnl': 'Unrealized PnL',
6465
'total_return': 'Total Return ($)',
6566
'total_return_pct': 'Total Return (%)',
67+
'annualized_return_pct': 'Annualized Return (%)',
6668
'sector': 'sector',
6769
'asset_class': 'Asset Class',
6870
'status': 'Status'
@@ -86,6 +88,7 @@ def render_holdings_table(holdings_data: pd.DataFrame):
8688
'Unrealized PnL': st.column_config.NumberColumn('Unrealized PnL', format='$%.2f'),
8789
'Total Return ($)': st.column_config.NumberColumn('Total Return ($)', format='$%.2f'),
8890
'Total Return (%)': st.column_config.NumberColumn('Total Return (%)', format='%.2f%%'),
91+
'Annualized Return (%)': st.column_config.NumberColumn('Annualized Return (%)', format='%.2f%%'),
8992
'Sector': st.column_config.TextColumn('sector'),
9093
'Asset Class': st.column_config.TextColumn('Asset Class'),
9194
'Status': st.column_config.TextColumn('Status'),

0 commit comments

Comments
 (0)