Skip to content

beacon.portfolio

The Portfolio accounting primitive: holdings, cash, transactions, valuation and weights, plus Excel reporting helpers. It has no dependency on assets or data sources — callers pass identifiers and prices directly.

portfolio

The init.py for the 'portfolio' module.

This module defines and manages investment portfolios, tracks holdings, transactions, and calculates portfolio values.

Holding dataclass

Holding(
    asset_id: str,
    quantity: float,
    average_cost_price: float,
    current_price: float | None = None,
    market_value: float | None = None,
)

Represents a holding of a specific asset in the portfolio. Mutable as quantity and market value change.

update_market_data

update_market_data(
    current_price: float, update_date: Timestamp
) -> None

Updates the holding with the latest market price and recalculates market value.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def update_market_data(self,
                       current_price: float,
                       update_date: pd.Timestamp) -> None:
    """Updates the holding with the latest market price and recalculates market value."""
    if current_price < 0:
        logger.warning(
            f"Attempted to update holding for {self.asset_id} with negative "
            f"price: {current_price}. Price not updated.")
        return
    self.current_price = current_price
    self.market_value = self.quantity * self.current_price

Portfolio

Portfolio(
    portfolio_id: str,
    initial_cash: float = 0.0,
    inception: Timestamp | None = None,
    source: DataFetcher | None = None,
)

Manages a collection of asset holdings, cash balance, and transaction history.

Holdings are keyed by string asset identifiers. The Portfolio has no dependency on Asset objects or DataFetcher — callers pass simple strings and prices.

It is also the store of record (BN-152): what it started with, and what the books said on every date something changed them, are kept here rather than flattened onto whatever ran it. See :attr:positions, :attr:cash and :attr:nav.

Parameters:

Name Type Description Default
portfolio_id str

Identifier for these books.

required
initial_cash float

Opening cash balance. Retained as :attr:initial_capital — cash_balance goes on mutating, so without it a portfolio could not say what it started with.

0.0
inception Timestamp | None

Optional day zero. When given, the books open on that date with NAV and cash equal to the initial capital, before anything trades. A backtest passes its start date; a hand-built portfolio can leave it out, and its history then starts at its first event.

None
Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def __init__(self,
             portfolio_id: str,
             initial_cash: float = 0.0,
             inception: pd.Timestamp | None = None,
             source: "DataFetcher | None" = None):
    if not portfolio_id:
        raise ValueError("portfolio_id cannot be empty.")
    if initial_cash < 0:
        raise ValueError("Initial cash cannot be negative.")

    self.portfolio_id: str = portfolio_id
    self.holdings: dict[str, Holding] = {}
    self.cash_balance: float = initial_cash
    self.transactions: list[Transaction] = []

    self.initial_capital: float = initial_cash

    # Where this portfolio's market questions are answered. Bound wins
    # over the process-level fallback (decision 16): the engine binds its
    # data provider here, so a backtest result's views always read the
    # data the run used.
    self.source: DataFetcher | None = source
    self.inception: pd.Timestamp | None = (
        pd.Timestamp(inception) if inception is not None else None)
    self.frozen: bool = False

    self._history = PortfolioHistory()

    if self.inception is not None:
        self._history.record(self.inception, self.holdings, self.cash_balance)

    logger.info(
        f"Portfolio '{self.portfolio_id}' initialized with cash: {self.cash_balance:.2f}")

positions property

positions: DataFrame

What was held, long-form: DATE, ASSET_ID, QUANTITY, PRICE, MARKET_VALUE, WEIGHT.

A row per held asset per date on which the books changed — a trade or a mark. WEIGHT was computed and stored at write time, so it records what the portfolio believed then rather than what recomputing it now would say.

weights property

weights: DataFrame

The stored weights, wide: dates by asset.

A pivot of the positions panel's WEIGHT column — the same recorded numbers, shaped for cross-book arithmetic: portfolio.weights subtracts cleanly against an index book's weights because both are date-by-asset frames (decision 5).

cash property

cash: Series

The cash balance on every recorded date.

nav property

nav: Series

Total value on every recorded date.

Starts at :attr:initial_capital on the inception date when one was given.

asset

asset(asset_id: str) -> PortfolioAssetView

One asset's position and market data, read live.

Parameters:

Name Type Description Default
asset_id str

An asset this portfolio holds or has ever held.

required

Returns:

Name Type Description
PortfolioAssetView PortfolioAssetView

Position numbers from these books, market

PortfolioAssetView

facts from the resolved data source.

Raises:

Type Description
KeyError

If the books have never seen asset_id — matching IndexResult.asset for a non-constituent.

DataSourceError

If no source is bound and the process has none.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def asset(self,
          asset_id: str) -> "PortfolioAssetView":
    """One asset's position and market data, read live.

    Args:
        asset_id: An asset this portfolio holds or has ever held.

    Returns:
        PortfolioAssetView: Position numbers from these books, market
        facts from the resolved data source.

    Raises:
        KeyError: If the books have never seen *asset_id* — matching
            `IndexResult.asset` for a non-constituent.
        DataSourceError: If no source is bound and the process has none.
    """
    known = set(self.holdings)
    positions = self.positions

    if not positions.empty:
        known |= set(positions["ASSET_ID"])

    if asset_id not in known:
        raise KeyError(
            f"Asset '{asset_id}' does not appear in this portfolio's "
            f"books.")

    fetcher = self.source if self.source is not None else resolve()

    return PortfolioAssetView(asset_id, fetcher, self)

freeze

freeze() -> None

Close the books: from here on, any write raises.

Called by the backtest engine when a run ends. The portfolio is then the record of that run, and a later trade against it would restate a result somebody may already have read. Idempotent.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def freeze(self) -> None:
    """Close the books: from here on, any write raises.

    Called by the backtest engine when a run ends. The portfolio is then
    the record of that run, and a later trade against it would restate a
    result somebody may already have read. Idempotent.
    """
    if not self.frozen:
        logger.info(f"Portfolio '{self.portfolio_id}' frozen.")

    self.frozen = True

execute_buy

execute_buy(
    asset_id: str,
    quantity: float,
    price: float,
    cost: float = 0.0,
    date: Timestamp | None = None,
) -> None

Buy an asset: deduct cash, create/update holding, record transaction.

Parameters:

Name Type Description Default
asset_id str

String identifier for the asset.

required
quantity float

Number of units to buy (must be positive).

required
price float

Execution price per unit.

required
cost float

Optional transaction cost (brokerage, taxes, etc.).

0.0
date Timestamp | None

Optional execution date. Defaults to now, and the history row is written under that same timestamp.

None

Raises:

Type Description
FrozenPortfolioError

If the books have been frozen.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def execute_buy(self,
                asset_id: str,
                quantity: float,
                price: float,
                cost: float = 0.0,
                date: pd.Timestamp | None = None) -> None:
    """Buy an asset: deduct cash, create/update holding, record transaction.

    Args:
        asset_id: String identifier for the asset.
        quantity: Number of units to buy (must be positive).
        price: Execution price per unit.
        cost: Optional transaction cost (brokerage, taxes, etc.).
        date: Optional execution date. Defaults to now, and the history
            row is written under that same timestamp.

    Raises:
        FrozenPortfolioError: If the books have been frozen.
    """
    self._refuse_if_frozen("execute_buy")

    if quantity <= 0:
        raise ValueError("quantity must be positive.")
    if price < 0:
        raise ValueError("price cannot be negative.")

    trade_value = quantity * price
    required = trade_value + cost

    # Compared with tolerance, not exactly. A caller spending down a
    # balance — the backtest engine selling and then reinvesting the
    # proceeds is the normal case — arrives here with a required amount
    # that differs from the balance only by accumulated float error, and an
    # exact comparison rejects a purchase the portfolio can plainly afford.
    if self.cash_balance < required * (1 - CASH_TOLERANCE):
        logger.error(
            f"Insufficient cash for BUY of {asset_id}. "
            f"Required: {required:.2f}, Available: {self.cash_balance:.2f}"
        )
        return

    tx_date = date if date is not None else pd.Timestamp.now()
    # Clamped at zero so a purchase accepted inside the tolerance cannot
    # leave a residual negative balance.
    self.cash_balance = max(self.cash_balance - required, 0.0)

    if asset_id in self.holdings:
        h = self.holdings[asset_id]
        old_total = h.average_cost_price * h.quantity
        h.quantity += quantity
        if h.quantity > 1e-9:
            h.average_cost_price = (old_total + trade_value) / h.quantity
        else:
            h.average_cost_price = price
    else:
        self.holdings[asset_id] = Holding(
            asset_id=asset_id, quantity=quantity, average_cost_price=price
        )

    logger.debug(f"BUY: {quantity} of {asset_id} @ {price:.2f}. Cash: {self.cash_balance:.2f}")

    self.transactions.append(
        Transaction(asset_id, quantity, price, 'BUY', tx_date, cost)
    )

    # Update market data using execution price
    self.holdings[asset_id].update_market_data(price, tx_date)

    self._record(tx_date)

execute_sell

execute_sell(
    asset_id: str,
    quantity: float,
    price: float,
    cost: float = 0.0,
    date: Timestamp | None = None,
) -> None

Sell an asset: add cash proceeds, reduce/remove holding, record transaction.

Parameters:

Name Type Description Default
asset_id str

String identifier for the asset.

required
quantity float

Number of units to sell (must be positive).

required
price float

Execution price per unit.

required
cost float

Optional transaction cost (brokerage, taxes, etc.).

0.0
date Timestamp | None

Optional execution date. Defaults to now, and the history row is written under that same timestamp.

None

Raises:

Type Description
FrozenPortfolioError

If the books have been frozen.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def execute_sell(self,
                 asset_id: str,
                 quantity: float,
                 price: float,
                 cost: float = 0.0,
                 date: pd.Timestamp | None = None) -> None:
    """Sell an asset: add cash proceeds, reduce/remove holding, record transaction.

    Args:
        asset_id: String identifier for the asset.
        quantity: Number of units to sell (must be positive).
        price: Execution price per unit.
        cost: Optional transaction cost (brokerage, taxes, etc.).
        date: Optional execution date. Defaults to now, and the history
            row is written under that same timestamp.

    Raises:
        FrozenPortfolioError: If the books have been frozen.
    """
    self._refuse_if_frozen("execute_sell")

    if quantity <= 0:
        raise ValueError("quantity must be positive.")
    if price < 0:
        raise ValueError("price cannot be negative.")

    if asset_id not in self.holdings or self.holdings[asset_id].quantity < quantity:
        current_qty = self.holdings[asset_id].quantity if asset_id in self.holdings else 0
        logger.error(
            f"Insufficient holdings for SELL of {asset_id}. "
            f"Attempting to sell: {quantity}, Available: {current_qty}"
        )
        return

    tx_date = date if date is not None else pd.Timestamp.now()
    trade_value = quantity * price
    self.cash_balance += (trade_value - cost)

    self.holdings[asset_id].quantity -= quantity
    logger.debug(f"SELL: {quantity} of {asset_id} @ {price:.2f}. Cash: {self.cash_balance:.2f}")

    if self.holdings[asset_id].quantity < 1e-9:
        logger.debug(f"Fully sold asset: {asset_id}. Removing from holdings.")
        del self.holdings[asset_id]
    else:
        # Re-mark what is left, at the price it just traded at. Without
        # this the remaining holding keeps the market value of the
        # *larger* position it used to be, so `get_total_value` overstates
        # the books until the next mark — and the position row written
        # below would record a quantity and a market value that disagree.
        self.holdings[asset_id].update_market_data(price, tx_date)

    self.transactions.append(
        Transaction(asset_id, quantity, price, 'SELL', tx_date, cost)
    )

    self._record(tx_date)

apply

apply(
    trade: TradeInstruction, date: Timestamp | None = None
) -> None

Record one trade in the books.

The entry point for anything that has already decided a trade — the engine, after sizing and pricing it. Dispatches to the buy/sell accounting, which stays on the portfolio: weighted average cost, closing at ~zero quantity, refusing entries that would push cash or holdings negative are what make a ledger a ledger, wherever the decision came from.

Parameters:

Name Type Description Default
trade TradeInstruction

The instruction, as the decider issued it.

required
date Timestamp | None

Execution date. Defaults to now, as the underlying accounting does.

None

Raises:

Type Description
ValueError

If the side is neither "BUY" nor "SELL" — refused rather than guessed, because silently ignoring an unknown side would drop a trade from the record.

FrozenPortfolioError

If the books have been frozen. Checked here as well as in the accounting, so the message names the entry point the caller actually used.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def apply(self,
          trade: TradeInstruction,
          date: pd.Timestamp | None = None) -> None:
    """Record one trade in the books.

    The entry point for anything that has already *decided* a trade —
    the engine, after sizing and pricing it. Dispatches to the buy/sell
    accounting, which stays on the portfolio: weighted average cost,
    closing at ~zero quantity, refusing entries that would push cash or
    holdings negative are what make a ledger a ledger, wherever the
    decision came from.

    Args:
        trade: The instruction, as the decider issued it.
        date: Execution date. Defaults to now, as the underlying
            accounting does.

    Raises:
        ValueError: If the side is neither ``"BUY"`` nor ``"SELL"`` —
            refused rather than guessed, because silently ignoring an
            unknown side would drop a trade from the record.
        FrozenPortfolioError: If the books have been frozen. Checked here
            as well as in the accounting, so the message names the entry
            point the caller actually used.
    """
    self._refuse_if_frozen("apply")

    side = trade.side.upper()

    if side == "BUY":
        self.execute_buy(trade.asset_id, trade.quantity, trade.price,
                         cost=trade.cost, date=date)
    elif side == "SELL":
        self.execute_sell(trade.asset_id, trade.quantity, trade.price,
                          cost=trade.cost, date=date)
    else:
        raise ValueError(
            f"Unknown trade side '{trade.side}'. Expected BUY or SELL.")

update_prices

update_prices(
    prices: dict[str, float], date: Timestamp | None = None
) -> None

Update current prices for holdings from a dictionary.

A mark changes what the books say the portfolio is worth, so it is recorded like a trade is.

Parameters:

Name Type Description Default
prices dict[str, float]

Mapping of asset_id to current price. Holdings whose asset_id is not in the dict are left unchanged with a warning.

required
date Timestamp | None

Optional date to mark as of. Defaults to now.

None

Raises:

Type Description
FrozenPortfolioError

If the books have been frozen.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def update_prices(self,
                  prices: dict[str, float],
                  date: pd.Timestamp | None = None) -> None:
    """
    Update current prices for holdings from a dictionary.

    A mark changes what the books say the portfolio is worth, so it is
    recorded like a trade is.

    Args:
        prices: Mapping of asset_id to current price.
                Holdings whose asset_id is not in the dict are left
                unchanged with a warning.
        date: Optional date to mark as of. Defaults to now.

    Raises:
        FrozenPortfolioError: If the books have been frozen.
    """
    self._refuse_if_frozen("update_prices")

    as_of = date if date is not None else pd.Timestamp.now()

    for asset_id, holding in self.holdings.items():
        price = prices.get(asset_id)
        if price is not None:
            holding.update_market_data(price, as_of)
        else:
            logger.warning(
                f"No price supplied for {asset_id}. "
                "Market value may be stale."
            )

    self._record(as_of)

get_total_value

get_total_value() -> float

Calculates the total current market value of the portfolio (holdings + cash).

Relies on prices having been set via :meth:update_prices, :meth:execute_buy, or :meth:execute_sell beforehand.

Returns:

Type Description
float

The total portfolio value as a float.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def get_total_value(self) -> float:
    """
    Calculates the total current market value of the portfolio (holdings + cash).

    Relies on prices having been set via :meth:`update_prices`,
    :meth:`execute_buy`, or :meth:`execute_sell` beforehand.

    Returns:
        The total portfolio value as a float.
    """
    total_holdings_value = 0.0
    for asset_id, holding in self.holdings.items():
        if holding.market_value is not None:
            total_holdings_value += holding.market_value
        else:
            logger.warning(
                f"Market value for {asset_id} is None. "
                "It will not be included in total portfolio value calculation "
                "based on market prices.")

    total_portfolio_value = total_holdings_value + self.cash_balance
    return total_portfolio_value

get_weights

get_weights() -> dict[str, float]

Calculates the current weight of each asset in the portfolio. Weights are based on last-updated market values.

Returns:

Type Description
dict[str, float]

A dictionary mapping asset_id strings to weight floats.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def get_weights(self) -> dict[str, float]:
    """
    Calculates the current weight of each asset in the portfolio.
    Weights are based on last-updated market values.

    Returns:
        A dictionary mapping asset_id strings to weight floats.
    """
    total_value = self.get_total_value()
    weights: dict[str, float] = {}

    if total_value == 0:
        logger.warning(
            f"Total portfolio value is 0. Cannot calculate asset weights for "
            f"portfolio '{self.portfolio_id}'.")
        for asset_id in self.holdings:
            weights[asset_id] = 0.0
        return weights

    for asset_id, holding in self.holdings.items():
        if holding.market_value is not None:
            weights[asset_id] = holding.market_value / total_value
        else:
            weights[asset_id] = 0.0
            logger.warning(f"Weight for {asset_id} is 0 due to missing market value.")

    return weights

get_holdings_summary

get_holdings_summary() -> pd.DataFrame

Returns a DataFrame summarizing current holdings.

Returns:

Type Description
DataFrame

A pandas DataFrame with columns: AssetID, Quantity,

DataFrame

AvgCostPrice, CurrentPrice, MarketValue, Weight.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/base.py
def get_holdings_summary(self) -> pd.DataFrame:
    """
    Returns a DataFrame summarizing current holdings.

    Returns:
        A pandas DataFrame with columns: AssetID, Quantity,
        AvgCostPrice, CurrentPrice, MarketValue, Weight.
    """
    portfolio_total_value = self.get_total_value()

    summary_data = []
    for asset_id, holding in self.holdings.items():
        weight = ((holding.market_value / portfolio_total_value)
                  if portfolio_total_value != 0 and holding.market_value is not None
                  else 0.0)
        summary_data.append({
            'AssetID': asset_id,
            'Quantity': holding.quantity,
            'AvgCostPrice': holding.average_cost_price,
            'CurrentPrice': holding.current_price,
            'MarketValue': holding.market_value,
            'Weight': weight
        })

    # Add cash row
    summary_data.append({
        'AssetID': 'CASH',
        'Quantity': 1.0,
        'AvgCostPrice': self.cash_balance,
        'CurrentPrice': self.cash_balance,
        'MarketValue': self.cash_balance,
        'Weight': ((self.cash_balance / portfolio_total_value)
                   if portfolio_total_value != 0
                   else (1.0 if self.cash_balance > 0 else 0.0))
    })

    return pd.DataFrame(summary_data)

Transaction dataclass

Transaction(
    asset_id: str,
    quantity: float,
    price: float,
    transaction_type: str,
    transaction_date: Timestamp,
    transaction_cost: float = 0.0,
)

Represents a single transaction (buy or sell) of an asset.

ReportGenerator

Generates various reports for a portfolio or backtest results.

Excel output needs the 'excel' extra; the dependency is checked when a report is generated, so the class itself is always constructible.

generate_holdings_report_excel

generate_holdings_report_excel(
    portfolio: Portfolio,
    report_path: str,
    valuation_date: Timestamp,
) -> None

Generates an Excel report summarizing the current portfolio holdings.

The report is built from the portfolio's own state, so the caller must have called portfolio.update_prices(...) beforehand for the holdings to carry current prices (and therefore current market values/weights).

Parameters:

Name Type Description Default
portfolio Portfolio

The Portfolio object to report on.

required
report_path str

The file path (including .xlsx extension) where the Excel report will be saved.

required
valuation_date Timestamp

The date the holdings are reported as of; used for logging and report labelling only.

required

Raises:

Type Description
MissingDependencyError

If openpyxl is not installed.

ReportingError

If there's an issue writing the file.

ValueError

If portfolio is None.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/reporting.py
def generate_holdings_report_excel(self,
                                   portfolio: Portfolio,
                                   report_path: str,
                                   valuation_date: pd.Timestamp) -> None:
    """
    Generates an Excel report summarizing the current portfolio holdings.

    The report is built from the portfolio's own state, so the caller must
    have called ``portfolio.update_prices(...)`` beforehand for the holdings
    to carry current prices (and therefore current market values/weights).

    Args:
        portfolio: The Portfolio object to report on.
        report_path: The file path (including .xlsx extension) where the Excel
                     report will be saved.
        valuation_date: The date the holdings are reported as of; used for
                        logging and report labelling only.

    Raises:
        MissingDependencyError: If openpyxl is not installed.
        ReportingError: If there's an issue writing the file.
        ValueError: If portfolio is None.
    """
    require("openpyxl", "Excel reporting")

    if portfolio is None:
        raise ValueError("Portfolio object must be provided.")
    if not report_path.endswith(".xlsx"):
        logger.warning(f"Report path '{report_path}' does not end with .xlsx. Appending it.")
        report_path += ".xlsx"

    logger.info(
        f"Generating holdings report for portfolio '{portfolio.portfolio_id}' as of "
        f"{valuation_date.strftime('%Y-%m-%d')} to '{report_path}'.")

    try:
        holdings_summary_df = portfolio.get_holdings_summary()

        if holdings_summary_df.empty:
            logger.warning(
                f"No holdings data to report for portfolio "
                f"'{portfolio.portfolio_id}'. Excel file will be empty or not created.")
            # Create an empty sheet or just return
            # For now, let's write an empty DataFrame if that's the case.

        with pd.ExcelWriter(report_path, engine='openpyxl') as writer:
            holdings_summary_df.to_excel(writer, sheet_name='HoldingsSummary', index=False)

            # You could add more sheets here, e.g., transaction history
            transactions_df = pd.DataFrame([vars(tx) for tx in portfolio.transactions])
            if not transactions_df.empty:
                transactions_df = self._normalise_transactions_df(transactions_df)
                transactions_df.to_excel(writer, sheet_name='TransactionHistory', index=False)

        logger.info(f"Holdings report successfully saved to {report_path}")

    except Exception as e:
        logger.error(f"Failed to generate or save holdings report to {report_path}: {e}")
        raise ReportingError(f"Error generating holdings report: {e}") from e

generate_performance_report_excel

generate_performance_report_excel(
    performance_data: DataFrame,
    report_path: str,
    report_title: str | None = "Performance Report",
) -> None

Generates an Excel report from a DataFrame of performance data. The performance_data DataFrame is typically the output of a backtest (e.g., daily portfolio values, returns) or specific analysis results.

Parameters:

Name Type Description Default
performance_data DataFrame

A pandas DataFrame containing performance metrics over time. Expected to have a DatetimeIndex.

required
report_path str

The file path (including .xlsx extension) for the report.

required
report_title str | None

An optional title for the report (used as sheet name or in header).

'Performance Report'

Raises:

Type Description
MissingDependencyError

If openpyxl is not installed.

ReportingError

If there's an issue writing the file.

ValueError

If performance_data is not a non-empty DataFrame.

Source code in build/cache/py-beacon-2c9c3936c65abdb6b8403c50a023f58355be30ee/src/beacon/portfolio/reporting.py
def generate_performance_report_excel(self,
                                      # Output from BacktestEngine or analysis
                                      performance_data: pd.DataFrame,
                                      report_path: str,
                                      report_title: str | None = "Performance Report") -> None:
    """
    Generates an Excel report from a DataFrame of performance data.
    The performance_data DataFrame is typically the output of a backtest
    (e.g., daily portfolio values, returns) or specific analysis results.

    Args:
        performance_data: A pandas DataFrame containing performance metrics over time.
                          Expected to have a DatetimeIndex.
        report_path: The file path (including .xlsx extension) for the report.
        report_title: An optional title for the report (used as sheet name or in header).

    Raises:
        MissingDependencyError: If openpyxl is not installed.
        ReportingError: If there's an issue writing the file.
        ValueError: If performance_data is not a non-empty DataFrame.
    """
    require("openpyxl", "Excel reporting")

    if not isinstance(performance_data, pd.DataFrame) or performance_data.empty:
        raise ValueError("performance_data must be a non-empty pandas DataFrame.")
    if not report_path.endswith(".xlsx"):
        logger.warning(f"Report path '{report_path}' does not end with .xlsx. Appending it.")
        report_path += ".xlsx"

    # Excel sheet name limits
    sheet_name = report_title.replace(" ", "_")[:30] if report_title else "PerformanceData"
    logger.info(f"Generating performance report '{sheet_name}' to '{report_path}'.")

    try:
        with pd.ExcelWriter(report_path, engine='openpyxl') as writer:
            # Assuming DatetimeIndex should be written
            performance_data.to_excel(writer, sheet_name=sheet_name, index=True)
        logger.info(f"Performance report successfully saved to {report_path}")
    except Exception as e:
        logger.error(f"Failed to generate or save performance report to {report_path}: {e}")
        raise ReportingError(f"Error generating performance report: {e}") from e