From e29129a9e562d5ded0e944b9d3b96069bd6d7548 Mon Sep 17 00:00:00 2001 From: Cory Virok Date: Thu, 26 Mar 2026 12:25:48 -0700 Subject: [PATCH] fix: gross_exposure() returns absolute values for grouped portfolios with short positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gross_exposure() was computing net signed exposure instead of gross exposure for grouped portfolios. For short-only positions, it returned negative values; for mixed long/short, it could return wildly inflated values (e.g. 6000%+ when actual gross exposure was ~50%). Root cause: when group_by is active, asset_value is the net sum of per-column values (longs - shorts). For gross exposure, we need the sum of absolute per-column values. The fix computes abs(per-column asset values) before group aggregation. The existing net_exposure() method is unaffected — it correctly computes long_exposure - short_exposure from direction-filtered gross_exposure calls. Fixes #836. Co-Authored-By: Claude Opus 4.6 --- tests/test_portfolio.py | 34 +++++++++++++++++----------------- vectorbt/portfolio/base.py | 17 ++++++++++++++--- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index ad731425..93611eca 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -6630,11 +6630,11 @@ def test_gross_exposure(self): ) result = pd.DataFrame( np.array([ - [0.0, -0.010214494162927312, 0.010012024441354066], - [0.00200208256628545, -0.022821548354919067, 0.021830620581035857], - [0.0, -0.022821548354919067, 0.002949383274126105], - [0.0, -0.04241418126633477, 0.0], - [0.050155728521486365, -0.12017991413866216, 0.0] + [0.0, 0.01000999998999, 0.010012024441354066], + [0.00200208256628545, 0.021825370842812494, 0.021830620581035857], + [0.0, 0.021825370842812494, 0.002949383274126105], + [0.0, 0.03909759620159034, 0.0], + [0.050155728521486365, 0.09689116931945001, 0.0] ]), index=price_na.index, columns=price_na.columns @@ -6651,11 +6651,11 @@ def test_gross_exposure(self): pf_shared.gross_exposure(group_by=False), pd.DataFrame( np.array([ - [0.0, -0.00505305454620791, 0.010012024441354066], - [0.0010005203706447724, -0.011201622483733716, 0.021830620581035857], - [0.0, -0.011201622483733716, 0.002949383274126105], - [0.0, -0.020585865497718882, 0.0], - [0.025038871596209537, -0.0545825965137659, 0.0] + [0.0, 0.0050024987481246875, 0.010012024441354066], + [0.0010005203706447724, 0.010956168751293576, 0.021830620581035857], + [0.0, 0.010956168751293576, 0.002949383274126105], + [0.0, 0.019771825228137207, 0.0], + [0.025038871596209537, 0.049210520540028384, 0.0] ]), index=price_na.index, columns=price_na.columns @@ -6663,11 +6663,11 @@ def test_gross_exposure(self): ) result = pd.DataFrame( np.array([ - [-0.00505305454620791, 0.010012024441354066], - [-0.010188689433972452, 0.021830620581035857], - [-0.0112078992458765, 0.002949383274126105], - [-0.02059752492931316, 0.0], - [-0.027337628293439265, 0.0] + [0.0050024987481246875, 0.010012024441354066], + [0.011958382893456152, 0.021830620581035857], + [0.010962173376438594, 0.002949383274126105], + [0.019782580537729116, 0.0], + [0.07392874356988736, 0.0] ]), index=price_na.index, columns=pd.Index(['first', 'second'], dtype='object', name='group') @@ -7268,7 +7268,7 @@ def test_stats(self): np.array([ pd.Timestamp('2020-01-01 00:00:00'), pd.Timestamp('2020-01-05 00:00:00'), pd.Timedelta('5 days 00:00:00'), 100.0, 98.88877000000001, -1.11123, 283.3333333333333, - 2.05906183131983, 0.42223000000000005, 1.6451238489727062, pd.Timedelta('3 days 08:00:00'), + 5.629250614065742, 0.42223000000000005, 1.6451238489727062, pd.Timedelta('3 days 08:00:00'), 2.0, 1.3333333333333333, 0.6666666666666666, -1.5042060606060605, 33.333333333333336, -98.38058805880588, -100.8038553855386, 143.91625412541256, -221.34645964596464, pd.Timedelta('2 days 12:00:00'), pd.Timedelta('2 days 00:00:00'), np.inf, 0.10827272727272726, @@ -7376,7 +7376,7 @@ def test_stats(self): pd.Series( np.array([ pd.Timestamp('2020-01-01 00:00:00'), pd.Timestamp('2020-01-05 00:00:00'), - pd.Timedelta('5 days 00:00:00'), 200.0, 194.95809, -2.520955, 275.0, -0.505305454620791, + pd.Timedelta('5 days 00:00:00'), 200.0, 194.95809, -2.520955, 275.0, 7.392873929961589, 0.82091, 2.46248125751388, pd.Timedelta('4 days 00:00:00'), 4, 2, 2, -4.512618181818182, 0.0, -54.450495049504966, -388.2424242424243, np.nan, -221.34645964596461, pd.NaT, pd.Timedelta('2 days 00:00:00'), 0.0, -0.2646459090909091, -20.095906945591288, diff --git a/vectorbt/portfolio/base.py b/vectorbt/portfolio/base.py index 7713a5c0..c7083286 100644 --- a/vectorbt/portfolio/base.py +++ b/vectorbt/portfolio/base.py @@ -4296,10 +4296,21 @@ def asset_value(self, direction: str = 'both', group_by: tp.GroupByLike = None, @cached_method def gross_exposure(self, direction: str = 'both', group_by: tp.GroupByLike = None, wrap_kwargs: tp.KwargsLike = None) -> tp.SeriesFrame: - """Get gross exposure.""" - asset_value = to_2d_array(self.asset_value(group_by=group_by, direction=direction)) + """Get gross exposure. + + Gross exposure is the sum of absolute position values divided by portfolio value. + For grouped portfolios with mixed long/short positions, per-column absolute values + are summed before dividing by group portfolio value.""" + if self.wrapper.grouper.is_grouped(group_by=group_by): + # For grouped portfolios, we need sum(abs(per_column)) per group, + # not abs(sum(per_column)). The latter gives net exposure, not gross. + asset_value_ungrouped = to_2d_array(self.asset_value(group_by=False, direction=direction)) + group_lens = self.wrapper.grouper.get_group_lens(group_by=group_by) + abs_asset_value = nb.asset_value_grouped_nb(np.abs(asset_value_ungrouped), group_lens) + else: + abs_asset_value = np.abs(to_2d_array(self.asset_value(group_by=group_by, direction=direction))) cash = to_2d_array(self.cash(group_by=group_by, free=True)) - gross_exposure = nb.gross_exposure_nb(asset_value, cash) + gross_exposure = nb.gross_exposure_nb(abs_asset_value, cash) return self.wrapper.wrap(gross_exposure, group_by=group_by, **merge_dicts({}, wrap_kwargs)) @cached_method