commit a6eb318183709289a50af1b9d90a7bed53d9048d
parent 78d20e7e18786ff8ca6fc3788fbea6541444a29d
Author: Erik Loualiche <[email protected]>
Date: Sun, 22 Mar 2026 12:08:05 -0500
Add calculate_portfolio_returns for equal/value-weighted portfolios
- Equal-weighted and value-weighted modes with optional grouping
- Handles missing returns gracefully
- Statistics added to [deps] for mean()
- Added combine, ncol, unique to DataFrames imports
- 9 test assertions
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Diffstat:
5 files changed, 123 insertions(+), 2 deletions(-)
diff --git a/Project.toml b/Project.toml
@@ -19,6 +19,7 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Missings = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28"
PeriodicalDates = "276e7ca9-e0d7-440b-97bc-a6ae82f545b1"
Roots = "f2b01f46-fcfa-551c-844a-d8ac1e96c665"
+Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
WeakRefStrings = "ea10d353-3f73-51f8-a26c-33c1cb351aa5"
ZipFile = "a5390f91-8eb1-5f08-bee0-b1d1ffed6cea"
diff --git a/src/FinanceRoutines.jl b/src/FinanceRoutines.jl
@@ -4,8 +4,8 @@ module FinanceRoutines
# --------------------------------------------------------------------------------------------------
import BazerData: tlag
import CSV
-import DataFrames: AbstractDataFrame, AsTable, DataFrame, DataFrameRow, ByRow, groupby, nrow, passmissing, Not,
- rename!, select, select!, subset, subset!, transform!, leftjoin, disallowmissing!
+import DataFrames: AbstractDataFrame, AsTable, DataFrame, DataFrameRow, ByRow, combine, groupby, ncol, nrow,
+ passmissing, Not, rename!, select, select!, subset, subset!, transform!, leftjoin, unique, disallowmissing!
import DataPipes: @p
import Dates: Dates, Date, Day, Month, year
import Decimals: Decimal
@@ -19,6 +19,7 @@ import LinearAlgebra: qr
import Missings: Missings, missing, disallowmissing
import PeriodicalDates: MonthlyDate
import Roots
+import Statistics: mean
import Tables: columntable
import WeakRefStrings: String3, String7, String15
import ZipFile
@@ -37,6 +38,7 @@ include("BondPricing.jl")
include("ImportCRSP.jl")
include("ImportComp.jl")
include("Merge_CRSP_Comp.jl")
+include("PortfolioUtils.jl")
# --------------------------------------------------------------------------------------------------
@@ -67,6 +69,7 @@ export link_MSF
# More practical functions
export calculate_rolling_betas
+export calculate_portfolio_returns
# --------------------------------------------------------------------------------------------------
diff --git a/src/PortfolioUtils.jl b/src/PortfolioUtils.jl
@@ -0,0 +1,73 @@
+# --------------------------------------------------------------------------------------------------
+# PortfolioUtils.jl
+
+# Portfolio-level return calculations
+# --------------------------------------------------------------------------------------------------
+
+
+# --------------------------------------------------------------------------------------------------
+"""
+ calculate_portfolio_returns(df, ret_col, date_col;
+ weighting=:value, weight_col=nothing, groups=nothing)
+
+Calculate portfolio returns from individual stock returns.
+
+# Arguments
+- `df::AbstractDataFrame`: Panel data with stock returns
+- `ret_col::Symbol`: Column name for returns
+- `date_col::Symbol`: Column name for dates
+
+# Keywords
+- `weighting::Symbol=:value`: `:equal` for equal-weighted, `:value` for value-weighted
+- `weight_col::Union{Nothing,Symbol}=nothing`: Column for weights (required if `weighting=:value`)
+- `groups::Union{Nothing,Symbol,Vector{Symbol}}=nothing`: Optional grouping columns (e.g., size quintile)
+
+# Returns
+- `DataFrame`: Portfolio returns by date (and group if specified), with column `:port_ret`
+
+# Examples
+```julia
+# Equal-weighted portfolio returns
+df_ew = calculate_portfolio_returns(df, :ret, :datem; weighting=:equal)
+
+# Value-weighted by market cap
+df_vw = calculate_portfolio_returns(df, :ret, :datem; weighting=:value, weight_col=:mktcap)
+
+# Value-weighted by group (e.g., size quintile)
+df_grouped = calculate_portfolio_returns(df, :ret, :datem;
+ weighting=:value, weight_col=:mktcap, groups=:size_quintile)
+```
+"""
+function calculate_portfolio_returns(df::AbstractDataFrame, ret_col::Symbol, date_col::Symbol;
+ weighting::Symbol=:value,
+ weight_col::Union{Nothing,Symbol}=nothing,
+ groups::Union{Nothing,Symbol,Vector{Symbol}}=nothing)
+
+ if weighting == :value && isnothing(weight_col)
+ throw(ArgumentError("weight_col required for value-weighted portfolios"))
+ end
+ if weighting ∉ (:equal, :value)
+ throw(ArgumentError("weighting must be :equal or :value, got :$weighting"))
+ end
+
+ group_cols = if isnothing(groups)
+ [date_col]
+ else
+ vcat([date_col], groups isa Symbol ? [groups] : groups)
+ end
+
+ grouped = groupby(df, group_cols)
+
+ if weighting == :equal
+ return combine(grouped, ret_col => (r -> mean(skipmissing(r))) => :port_ret)
+ else
+ return combine(grouped,
+ [ret_col, weight_col] => ((r, w) -> begin
+ valid = .!ismissing.(r) .& .!ismissing.(w)
+ any(valid) || return missing
+ rv, wv = r[valid], w[valid]
+ sum(rv .* wv) / sum(wv)
+ end) => :port_ret)
+ end
+end
+# --------------------------------------------------------------------------------------------------
diff --git a/test/UnitTests/PortfolioUtils.jl b/test/UnitTests/PortfolioUtils.jl
@@ -0,0 +1,43 @@
+@testset "Portfolio Return Calculations" begin
+
+ import Dates: Date, Month
+
+ # Create test data: 3 stocks, 12 months
+ dates = repeat(Date(2020,1,1):Month(1):Date(2020,12,1), inner=3)
+ df = DataFrame(
+ datem = dates,
+ permno = repeat([1, 2, 3], 12),
+ ret = rand(36) .* 0.1 .- 0.05,
+ mktcap = repeat([100.0, 200.0, 300.0], 12)
+ )
+
+ # Equal-weighted returns
+ df_ew = calculate_portfolio_returns(df, :ret, :datem; weighting=:equal)
+ @test nrow(df_ew) == 12
+ @test "port_ret" in names(df_ew)
+
+ # Value-weighted returns
+ df_vw = calculate_portfolio_returns(df, :ret, :datem;
+ weighting=:value, weight_col=:mktcap)
+ @test nrow(df_vw) == 12
+ @test "port_ret" in names(df_vw)
+
+ # Grouped portfolios (e.g., by size group)
+ df.group = repeat([1, 1, 2], 12)
+ df_grouped = calculate_portfolio_returns(df, :ret, :datem;
+ weighting=:value, weight_col=:mktcap,
+ groups=:group)
+ @test nrow(df_grouped) == 24 # 12 months x 2 groups
+
+ # Error cases
+ @test_throws ArgumentError calculate_portfolio_returns(df, :ret, :datem; weighting=:value)
+ @test_throws ArgumentError calculate_portfolio_returns(df, :ret, :datem; weighting=:foo)
+
+ # Missing handling
+ allowmissing!(df, :ret)
+ df.ret[1] = missing
+ df_ew2 = calculate_portfolio_returns(df, :ret, :datem; weighting=:equal)
+ @test nrow(df_ew2) == 12
+ @test !ismissing(df_ew2.port_ret[1]) # should compute from non-missing stocks
+
+end
diff --git a/test/runtests.jl b/test/runtests.jl
@@ -17,6 +17,7 @@ const testsuite = [
"WRDS",
"betas",
"Yields",
+ "PortfolioUtils",
]
# --------------------------------------------------------------------------------------------------