Portfolio¶
Performance says how a run did. PortfolioAnalytics says
what it held, how much it traded and why, and what that cost: its weights and
groups, its turnover, its costs, the characteristics of what it held, its
income and flows, and the figures particular to an ETF, a fund with share
classes or one holding index futures.
A finished backtest carries it as result.portfolio_analytics. For a stored
run, build it from the run's data: PortfolioAnalytics(RunData.from_record(folder)).
It reads the run's tables (result.run_data.tables), which are built the
first time any is asked for.
Weights and groups¶
import logging
from beacon.backtest import Backtest
from beacon.index.constructor import IndexDefinition
from beacon.index.methodology import MarketCapWeighted
from beacon.testing import dataset
logging.getLogger("beacon").setLevel(logging.ERROR) # keep the output short
definition = IndexDefinition(
index_id="SAMPLE", index_name="Sample Market-Cap Index",
base_date="2023-01-03", base_value=1000.0, currency="USD",
eligibility_rules=[], weighting_scheme=MarketCapWeighted(),
rebalancing_frequency="QUARTERLY", calendar="XNYS",
universe_identifiers=["AAA", "BBB", "CCC", "DDD", "EEE"],
)
backtest = Backtest(initial_capital=1_000_000.0, transaction_cost_bps=5.0,
data_provider=dataset.data_fetcher())
result = backtest.run(definition, start="2023-01-03", end="2024-12-31")
portfolio = result.portfolio_analytics
print(portfolio.weights().tail(1)) # dates by name
print(portfolio.holdings_count().iloc[-1]) # 5
print(portfolio.group_weights("SECTOR").tail(1))
weights(book) is a portfolio's daily weights, dates by name, for fund,
index or benchmark. It is None for a portfolio the run kept no weights
for, such as a benchmark given as a level series. holdings_count(book) is
how many names it held each day.
group_weights(field, book) adds the weights up by a grouping field of the
run's reference data: SECTOR, CURRENCY and EXCHANGE in the sample, and
whatever else the data gives. A name's group on a day is the one it had at
the latest rebalance on or before that day, and a name with no group recorded
is in Unknown. The groups add up to the invested weight, not to 1: the
fund's cash is in none of them. A field the run kept no reference data for
raises a KeyError naming the fields it has.
active_group_weights(field, against) is the fund's weight in each group
less the index's or benchmark's, on the fund's days. The sample tracks its
index, so its active weights are near zero.
Turnover and trading¶
A market-cap index's weights move with prices, so the sample does not trade again after buying in. An equal-weighted index over the same names rebalances back to equal weights each quarter, which gives turnover to look at.
from beacon.index.methodology import EqualWeighted
equal = IndexDefinition(
index_id="EQUAL", index_name="Sample Equal-Weighted Index",
base_date="2023-01-03", base_value=1000.0, currency="USD",
eligibility_rules=[], weighting_scheme=EqualWeighted(),
rebalancing_frequency="QUARTERLY", calendar="XNYS",
universe_identifiers=["AAA", "BBB", "CCC", "DDD", "EEE"],
)
trading = backtest.run(equal, start="2023-01-03", end="2024-12-31").portfolio_analytics
print(trading.turnover("rebalance")[["one_way", "two_way"]].round(4))
print(trading.turnover("year")[["one_way", "two_way"]].round(4))
print(trading.trading_by_reason())
turnover(by) gives the buys, sells, number of trades, mean trade, and
one-way and two-way turnover, by day (every trading day, with whether it
was a rebalance), rebalance (each rebalance's own trades) or year.
- One-way turnover is the lesser of what was bought and what was sold, over the fund's assets: the SEC's measure. Money arriving or leaving is bought or sold on one side only, so it does not inflate it.
- Two-way turnover is everything bought and sold over the same assets.
A day's and a rebalance's are over the NAV at the close before (the initial capital on the first day); a year's over its mean daily NAV. So the opening purchase is about 100% two-way turnover on the first day and in the first year, and 0 one-way.
Why each trade was made¶
Each trade has one reason, the event that triggered it, and a set of
tags: the reason, what it implies, and anything else that shaped it. The
run's trades table (result.run_data.tables["trades"]) has both, as REASON
and TAGS (joined by "; ").
| Reason | The trade was made |
|---|---|
rebalance |
At a rebalance, toward the new target |
subscription, redemption |
To invest money arriving, or raise money leaving |
cash raise, cash spend-down |
By the cash band, selling back up to the buffer or investing the excess |
fee funding |
By the cash band, selling back up to the buffer when the fees paid and owed since the cash was last at its target are at least half of what it raises |
dividend reinvestment |
To reinvest a dividend |
settlement |
To invest cash a derivative paid, or raise cash one is owed: a swap's value, futures margin |
fx hedge |
To invest what a currency hedge settled for, or raise what its settlement or cost needs (also tagged settlement for a settlement) |
corporate action |
To carry out a corporate action |
completion |
To work an order an execution limit left unfilled |
A rebalance trade is also tagged with any stage that changed the name's
target: diversification (the index's cap or the vehicle's limits),
liquidity, exclusion (a screen), position limit (the smallest position
allowed); and model-driven for an active strategy. Any trade whose reason
moves cash is tagged cash raise when it sells and cash spend-down when it
buys, whatever moved the cash. beacon.portfolio.reasons names a few more
(index change, drift, discretionary, risk-driven and
distribution funding) that no part of the engine records yet: a
distribution is paid from the income it distributes, so nothing is sold for
it.
The derivatives' own trades are recorded with them, each with a reason and a
kind: a futures trade is a futures roll (kind roll) or sets the
futures' exposure, futures exposure (kind open or resize); a hedged
class's forward trade is an fx hedge (kind open, roll or band).
Figures by reason add up to the run's; figures by tag may count a trade
more than once. trading_by_reason() gives each reason's trades, buys,
sells and share of all traded. cash_trading() gives every trade tagged a
cash raise or spend-down, by tag and reason.
Costs¶
print(trading.costs()) # over the run, with basis points a year
print(trading.cost_per_traded())
print(trading.costs_by_reason())
costs(by) gives the costs by kind, fixed (the fixed cost of each trade),
impact (its market impact), futures_rolls and futures_trading, then the
creation_fees_received and the net. Creation fees are received by the
fund, so they are set against its costs: the net is what was paid less
them.
- Over the
run, each kind'stotalandbps_a_year: the total over the mean NAV times the years, the years being the NAV's days overperiods_per_year. - Each
year, each kind's total andnet_bps, the net over that year's mean NAV for its own days, not annualised.
cost_per_traded() is the fixed cost, the market impact and both in basis
points of the value traded. costs_by_reason() is each reason's cost, impact,
value traded and cost in basis points of it. Both cover the trades of shares
only, so they add up to the run's trading costs, without the futures' costs
or the creation fees.
Characteristics¶
characteristics() gives the weighted market cap, P/E, P/B and dividend
yield of the fund, its index and its benchmark (when it holds names) at each
rebalance, from each portfolio's weights at the end of that day: a row a
rebalance, columns by portfolio and figure. Each is weighted over the names
with a value, the weights scaled to them. Market cap and dividend yield are
weighted means; P/E and P/B are weighted harmonic means, taken through the
earnings and book yields, so a loss or a tiny book does not blow them up. A
figure the data does not give is None: the sample has no P/E or P/B.
The values are the ones in force on each rebalance date, kept with the run: a
market cap is the close times shares outstanding in the portfolio's currency,
P/E and P/B the pe_ratio and pb_ratio features, and the dividend yield the
ordinary dividends over the trailing year over the close.
Income, AUM and flows¶
| Method | Gives |
|---|---|
income() |
Each day's distribution yield (paid out over the year to it, over that day's NAV) and trailing dividend yield (the gross dividends the fund became owed over the year, over its mean NAV over the year); blank for the run's first year |
dividend_growth() |
Each calendar year's gross dividends, and their growth on the year before |
aum() |
The fund's assets under management (its NAV) and units outstanding each day |
net_flows(frequency) |
Each month's (M) or year's (Y) net flows, money in less money out as dealt, and their share of the AUM at the close before it began |
flow_costs() |
For each day with flows: the flow, the trading it caused, the creation fees received and the net cost |
A flow's trading cost is every trading cost of its day when the day had no rebalance. On a rebalance day, the flow caused only part of the trading, so it is charged the share of the day's costs that its size is of the value traded, at most all of them.
Dealing, share classes and cash management¶
These describe flows, share classes and the cash band, which the sample has none of, so its tables are empty.
dealing_costs() gives each day's subscription or redemption (an ETF's
creation or redemption): its kind and size, the trading it caused, what the
dealing investors paid into the fund toward it through the price or a levy,
the net cost to the fund and in basis points of the flow. For an ETF it adds
ap_cost, the flow times the quote's cost of creating or redeeming that day.
dealing_summary(by) adds them up by kind over the run or each year.
class_costs(by) gives each share class's costs in the portfolio's
currency: its fees; its hedge_costs less the hedge_carry its forwards
earned (each forward's locked-in carry for the share of its term it was
held); the flow_trading its own flows caused, less the dealing_received
from its dealing investors; and its other_trading, its share of the rest of
the fund's trading (rebalances, dividends, the cash band, futures) by its
share of the fund's assets at the close before. The classes' trading adds up
to the fund's. Over the run the net is also in basis points of the class's
mean assets a year, each year of that year's. A run without share classes
raises a KeyError.
cash_band_events() lists each time the cash left its band and was raised
or spent back to its target: the cash before and after, the target and band,
what was traded and its cost, the gap to the target before and after, the
share of the gap closed (1 is cash brought exactly to its target) and the
cost in basis points of the value traded. cash_band_summary() totals them
for raises, spend-downs and both, and cash_band_calendar(frequency) counts
them and their cost each month or year. See the cash band in
backtest.
print(portfolio.flow_costs().empty, portfolio.cash_band_events().empty) # True True
print(portfolio.etf_premium()) # None
ETFs and futures¶
etf_premium() is an ETF's premium
to NAV on the exchange (its price over its NAV, less one): the mean, its
standard deviation, the widest premium and widest discount, and the mean
bid-ask spread. It is None for any other vehicle.
For a fund holding index futures,
each method below raises a KeyError on a run that held none:
| Method | Gives |
|---|---|
futures_trading() |
A row for each kind of trade (open, roll, resize), one for between_rolls (all but rolls) and one for all: trades, contracts and notional traded, cost, cost per contract and in basis points of the notional, and turnover (the notional a year over the mean NAV) |
futures_basis(daily) |
The basis (the futures' price over their index's level, less one), split into the carry (fair value over the level) and the mispricing (the price over fair value): each day, or their mean, standard deviation, lowest and highest in basis points |
futures_basis_risk() |
The tracking error the futures bring, split into the pricing basis (the futures' daily return less their own index's) and the underlying mismatch (their index's less the fund's index's), each with its mean a year and the days measured |
The underlying mismatch brings tracking error even at fair value with no costs. A day the position rolled is left out of the basis risk, since the price moved to another contract.
Where it sits¶
Downstream of the backtest, beside performance: both read the same run, finished or stored, and compute rather than re-simulate. The risk of what the run held is in risk analysis and the risk model; where its return came from is in attribution. Which of these figures a report shows is in reports.
The full API is in the analysis reference.