FinanceRoutines.jl

Financial data routines for Julia
Log | Files | Refs | README | LICENSE

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:
MProject.toml | 1+
Msrc/FinanceRoutines.jl | 7+++++--
Asrc/PortfolioUtils.jl | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/UnitTests/PortfolioUtils.jl | 43+++++++++++++++++++++++++++++++++++++++++++
Mtest/runtests.jl | 1+
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", ] # --------------------------------------------------------------------------------------------------