Skip to content

core

Asset-class tagging, calendar alignment, currency conversion, and weight normalization - all opt-in helpers you call before constructing a Portfolio. See the Calendars & currencies and Weights & short positions guides for worked examples.

Asset tagging

AssetClass

Bases: str, Enum

Optional tag for an asset's broad class.

Passing these to :class:~portpy.portfolio.Portfolio (via asset_classes=) is purely informational - it powers grouping in tearsheets and reminds you which assets trade around the clock (crypto) vs. only on business days (equities, bonds), but PortPy never uses it to silently reindex or fill your data.

ALWAYS_ON_CLASSES module-attribute

ALWAYS_ON_CLASSES = frozenset(
    {AssetClass.CRYPTO, AssetClass.FX}
)

Calendar alignment

detect_frequency

detect_frequency(index: DatetimeIndex) -> str

Roughly classify a DatetimeIndex's spacing (informational only).

Returns one of: "daily-7" (includes weekends, e.g. crypto), "daily-5" (business days, e.g. equities/bonds), "weekly", "monthly", "irregular", or "unknown" (too few points to tell).

Daily-or-lower-frequency only: spacing is bucketed on .dt.days, which truncates to whole days, so any sub-daily/intraday index has dt.days == 0 between consecutive timestamps and gets mislabeled "daily-5"/"daily-7" instead of a genuine intraday frequency. Don't rely on this for intraday data - the same applies to calendar_coverage_report, which calls it.

Source code in src/portpy/core/calendar.py
def detect_frequency(index: pd.DatetimeIndex) -> str:
    """
    Roughly classify a DatetimeIndex's spacing (informational only).

    Returns one of: "daily-7" (includes weekends, e.g. crypto), "daily-5" (business
    days, e.g. equities/bonds), "weekly", "monthly", "irregular", or "unknown" (too
    few points to tell).

    Daily-or-lower-frequency only: spacing is bucketed on .dt.days, which
    truncates to whole days, so any sub-daily/intraday index has dt.days == 0
    between consecutive timestamps and gets mislabeled "daily-5"/"daily-7" instead
    of a genuine intraday frequency. Don't rely on this for intraday data - the
    same applies to calendar_coverage_report, which calls it.
    """
    if len(index) < 3:
        return "unknown"
    diffs = pd.Series(index).diff().dropna().dt.days
    median = diffs.median()
    if median <= 1.5:
        weekdays = pd.Index(index).dayofweek
        has_weekend = bool(((weekdays == 5) | (weekdays == 6)).any())
        return "daily-7" if has_weekend else "daily-5"
    if median <= 4:
        return "weekly"
    if median <= 10:
        return "biweekly"
    if median <= 35:
        return "monthly"
    return "irregular"

calendar_coverage_report

calendar_coverage_report(prices: DataFrame) -> pd.DataFrame

Per-asset diagnostic: detected frequency, date range, and NaN count.

Run this before choosing an align_calendars method - it tells you which assets are actually mismatched and by how much.

Parameters:

Name Type Description Default
prices DataFrame

Columns = asset symbols, index = dates.

required

Returns:

Type Description
DataFrame

A DataFrame indexed by asset, with columns "frequency", "first_date",

DataFrame

"last_date", "n_observations", and "n_missing_in_frame".

Source code in src/portpy/core/calendar.py
def calendar_coverage_report(prices: pd.DataFrame) -> pd.DataFrame:
    """
    Per-asset diagnostic: detected frequency, date range, and NaN count.

    Run this before choosing an align_calendars method - it tells you which
    assets are actually mismatched and by how much.

    Args:
        prices: Columns = asset symbols, index = dates.

    Returns:
        A DataFrame indexed by asset, with columns "frequency", "first_date",
        "last_date", "n_observations", and "n_missing_in_frame".
    """
    rows = []
    for col in prices.columns:
        s = prices[col].dropna()
        rows.append(
            {
                "asset": col,
                "frequency": detect_frequency(pd.DatetimeIndex(s.index)),
                "first_date": s.index.min() if len(s) else pd.NaT,
                "last_date": s.index.max() if len(s) else pd.NaT,
                "n_observations": len(s),
                "n_missing_in_frame": int(prices[col].isna().sum()),
            }
        )
    return pd.DataFrame(rows).set_index("asset")

align_calendars

align_calendars(
    prices: dict[str, Series] | DataFrame,
    method: str = "intersection",
) -> pd.DataFrame

Combine assets that trade on different calendars into one aligned DataFrame.

Parameters:

Name Type Description Default
prices dict[str, Series] | DataFrame

Either a dict of {asset_name: price_series} or an already- combined DataFrame (columns = assets) that may contain NaNs from mismatched calendars.

required
method str

One of:

  • "intersection": keep only dates where every asset has a price (drops all weekend/holiday rows if any asset is business-days-only). Use this to analyze on the slowest asset's calendar (e.g. equities).
  • "ffill_union": take the union of all dates, forward-fill gaps (e.g. carries Friday's stock close through the weekend), then drop any remaining leading NaNs. Use this to keep crypto's 24/7 calendar while still pricing equities every day.
  • "business_days": reindex everything onto a Mon-Fri business-day calendar, forward-filling gaps, then drop remaining leading NaNs. Use this to standardize on the equity/bond calendar even if the combined frame currently has 7-day-a-week rows.
'intersection'

Returns:

Type Description
DataFrame

A DataFrame with no NaNs, ready to pass to

DataFrame

class:~portpy.portfolio.Portfolio.

"ffill_union" and "business_days" both forward-fill prices on days an asset didn't trade, which is equivalent to inserting a fabricated 0% return for that asset on those days - this mechanically drags down measured correlation/covariance (and beta) between assets on different calendars, a stale-price effect related to non-synchronous-trading bias. "business_days" additionally reindexes to pd.bdate_range (Mon-Fri) before forward-filling, so for a continuously-traded asset (e.g. crypto) it silently discards real weekend price action rather than just filling a gap. "intersection" avoids both problems (at the cost of a shorter sample) and is generally the safer default for correlation/beta/covariance work across heterogeneous calendars.

Source code in src/portpy/core/calendar.py
def align_calendars(
    prices: dict[str, pd.Series] | pd.DataFrame,
    method: str = "intersection",
) -> pd.DataFrame:
    """
    Combine assets that trade on different calendars into one aligned DataFrame.

    Args:
        prices: Either a dict of {asset_name: price_series} or an already-
            combined DataFrame (columns = assets) that may contain NaNs from
            mismatched calendars.
        method: One of:

            - "intersection": keep only dates where every asset has a price
              (drops all weekend/holiday rows if any asset is business-days-only).
              Use this to analyze on the slowest asset's calendar (e.g. equities).
            - "ffill_union": take the union of all dates, forward-fill gaps
              (e.g. carries Friday's stock close through the weekend), then drop
              any remaining leading NaNs. Use this to keep crypto's 24/7 calendar
              while still pricing equities every day.
            - "business_days": reindex everything onto a Mon-Fri business-day
              calendar, forward-filling gaps, then drop remaining leading NaNs.
              Use this to standardize on the equity/bond calendar even if the
              combined frame currently has 7-day-a-week rows.

    Returns:
        A DataFrame with no NaNs, ready to pass to
        :class:~portpy.portfolio.Portfolio.

    "ffill_union" and "business_days" both forward-fill prices on days an
    asset didn't trade, which is equivalent to inserting a fabricated 0% return
    for that asset on those days - this mechanically drags down measured
    correlation/covariance (and beta) between assets on different calendars, a
    stale-price effect related to non-synchronous-trading bias. "business_days"
    additionally reindexes to pd.bdate_range (Mon-Fri) before forward-filling,
    so for a continuously-traded asset (e.g. crypto) it silently discards real
    weekend price action rather than just filling a gap. "intersection" avoids
    both problems (at the cost of a shorter sample) and is generally the safer
    default for correlation/beta/covariance work across heterogeneous calendars.
    """
    if method not in _METHODS:
        raise ValueError(f"method must be one of {_METHODS}, got {method!r}.")

    if isinstance(prices, dict):
        df = pd.concat(prices, axis=1)
    else:
        df = prices.copy()
    df = df.sort_index()

    if method == "intersection":
        return df.dropna(how="any")
    if method == "ffill_union":
        return df.ffill().dropna(how="any")
    # business_days
    bdays = pd.bdate_range(df.index.min(), df.index.max())
    return df.reindex(bdays).ffill().dropna(how="any")

Currency conversion

convert_to_base_currency

convert_to_base_currency(
    prices: DataFrame,
    fx_rates: DataFrame,
    asset_currencies: dict[str, str],
    base_currency: str,
    max_staleness: int | None = None,
) -> pd.DataFrame

Convert a multi-asset price DataFrame into a single base currency.

Parameters:

Name Type Description Default
prices DataFrame

Columns = asset symbols, index = dates, values = prices in each asset's native currency.

required
fx_rates DataFrame

Columns = 3-letter currency codes, index = dates, values = units of base_currency per 1 unit of that currency - a direct quote (e.g. an "EUR" column of 1.08 means 1 EUR = 1.08 of base_currency; invert an indirect vendor quote with 1 / rate before passing it in). Reindexed to prices.index and forward-filled, so it doesn't need to share the exact same calendar.

required
asset_currencies dict[str, str]

Mapping of asset symbol -> currency code. Assets already in base_currency (or missing from this dict) are left untouched.

required
base_currency str

The target currency code (e.g. "USD").

required
max_staleness int | None

If given, caps how many consecutive rows a forward-filled FX rate may be carried for (DataFrame.ffill(limit=max_staleness)) before it's treated as missing again (NaN, which then propagates into the converted price - a visible failure rather than a silently stale rate). None (default) keeps the old unlimited-ffill behavior.

None

Returns:

Type Description
DataFrame

A new DataFrame, same shape as prices, denominated in base_currency.

Raises:

Type Description
ValueError

If an asset's currency has no matching column in fx_rates.

fx_rates must hold direct quotes (units of base_currency per unit of local currency). Most vendor data quotes pairs the other way round (e.g. USDJPY = JPY per USD, an indirect quote from a USD-base perspective) - feeding that in as-is does not raise, it silently multiplies prices by roughly 100-150x too much or too little; invert with 1 / rate if your feed is indirect. There's still no triangulation - if you have USD/EUR and USD/JPY but need EUR/JPY directly, this raises rather than cross-computing it. max_staleness addresses the other historical gap (an unlimited ffill silently carrying forward a months-old rate) but only if you set it - it stays unlimited by default for backward compatibility.

Source code in src/portpy/core/currency.py
def convert_to_base_currency(
    prices: pd.DataFrame,
    fx_rates: pd.DataFrame,
    asset_currencies: dict[str, str],
    base_currency: str,
    max_staleness: int | None = None,
) -> pd.DataFrame:
    """
    Convert a multi-asset price DataFrame into a single base currency.

    Args:
        prices: Columns = asset symbols, index = dates, values = prices in each
            asset's native currency.
        fx_rates: Columns = 3-letter currency codes, index = dates, values = units
            of base_currency per 1 unit of that currency - a direct quote (e.g.
            an "EUR" column of 1.08 means 1 EUR = 1.08 of base_currency; invert
            an indirect vendor quote with 1 / rate before passing it in).
            Reindexed to prices.index and forward-filled, so it doesn't need to
            share the exact same calendar.
        asset_currencies: Mapping of asset symbol -> currency code. Assets already
            in base_currency (or missing from this dict) are left untouched.
        base_currency: The target currency code (e.g. "USD").
        max_staleness: If given, caps how many consecutive rows a forward-filled
            FX rate may be carried for (`DataFrame.ffill(limit=max_staleness)`)
            before it's treated as missing again (`NaN`, which then propagates
            into the converted price - a visible failure rather than a silently
            stale rate). `None` (default) keeps the old unlimited-ffill behavior.

    Returns:
        A new DataFrame, same shape as prices, denominated in base_currency.

    Raises:
        ValueError: If an asset's currency has no matching column in fx_rates.

    fx_rates must hold direct quotes (units of base_currency per unit of
    local currency). Most vendor data quotes pairs the other way round (e.g.
    USDJPY = JPY per USD, an indirect quote from a USD-base perspective) -
    feeding that in as-is does not raise, it silently multiplies prices by
    roughly 100-150x too much or too little; invert with 1 / rate if your feed
    is indirect. There's still no triangulation - if you have USD/EUR and
    USD/JPY but need EUR/JPY directly, this raises rather than cross-computing
    it. `max_staleness` addresses the other historical gap (an unlimited ffill
    silently carrying forward a months-old rate) but only if you set it - it
    stays unlimited by default for backward compatibility.
    """
    converted = prices.copy()
    fx_aligned = fx_rates.reindex(prices.index).ffill(limit=max_staleness)

    for asset, ccy in asset_currencies.items():
        if asset not in converted.columns or ccy == base_currency:
            continue
        if ccy not in fx_aligned.columns:
            raise ValueError(
                f"No FX rate column found for currency {ccy!r} (needed by asset {asset!r}). "
                f"fx_rates columns: {list(fx_rates.columns)}"
            )
        converted[asset] = converted[asset] * fx_aligned[ccy]

    return converted

calculate_fx_spread

calculate_fx_spread(
    bid_rates: DataFrame, ask_rates: DataFrame
) -> pd.DataFrame

FX bid-ask spread as a fraction of the mid rate, per currency per date.

Parameters:

Name Type Description Default
bid_rates DataFrame

Columns = 3-letter currency codes, index = dates, values = the rate you'd receive selling that currency (units of base currency per unit of local currency, same direct-quote convention as convert_to_base_currency's fx_rates).

required
ask_rates DataFrame

Same shape/columns/convention as bid_rates - the rate you'd pay buying that currency. Must be columned identically to bid_rates (same currency set); reindexed to bid_rates.index.

required

Returns:

Type Description
DataFrame

(ask - bid) / mid per currency per date, where mid = (ask+bid)/2 -

DataFrame

a dimensionless spread fraction (0.001 = a 10 bps quoted spread),

DataFrame

feed straight into metrics.costs.fx_spread_costs as fx_spread_bps

DataFrame

after multiplying by 10,000.

Raises:

Type Description
ValueError

If bid_rates and ask_rates don't share the same columns.

This is the quoted spread implied by two rate series you already have - it doesn't fetch or estimate anything itself, and it says nothing about the cost of trading through more than the top of book (a large order can walk through several price levels, paying more than half this spread). Treat it as a lower bound on realistic FX conversion cost, not the whole story.

Source code in src/portpy/core/currency.py
def calculate_fx_spread(bid_rates: pd.DataFrame, ask_rates: pd.DataFrame) -> pd.DataFrame:
    """
    FX bid-ask spread as a fraction of the mid rate, per currency per date.

    Args:
        bid_rates: Columns = 3-letter currency codes, index = dates, values =
            the rate you'd receive selling that currency (units of base
            currency per unit of local currency, same direct-quote convention
            as `convert_to_base_currency`'s `fx_rates`).
        ask_rates: Same shape/columns/convention as `bid_rates` - the rate
            you'd pay buying that currency. Must be columned identically to
            `bid_rates` (same currency set); reindexed to `bid_rates.index`.

    Returns:
        `(ask - bid) / mid` per currency per date, where `mid = (ask+bid)/2` -
        a dimensionless spread fraction (0.001 = a 10 bps quoted spread),
        feed straight into `metrics.costs.fx_spread_costs` as `fx_spread_bps`
        after multiplying by 10,000.

    Raises:
        ValueError: If `bid_rates` and `ask_rates` don't share the same columns.

    This is the *quoted* spread implied by two rate series you already have -
    it doesn't fetch or estimate anything itself, and it says nothing about
    the cost of trading through more than the top of book (a large order can
    walk through several price levels, paying more than half this spread).
    Treat it as a lower bound on realistic FX conversion cost, not the whole story.
    """
    if set(bid_rates.columns) != set(ask_rates.columns):
        raise ValueError(
            f"bid_rates and ask_rates must have the same currency columns, "
            f"got {sorted(bid_rates.columns)} vs {sorted(ask_rates.columns)}."
        )
    ask_aligned = ask_rates.reindex(index=bid_rates.index, columns=bid_rates.columns)
    mid = (ask_aligned + bid_rates) / 2.0
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", category=RuntimeWarning)
        spread = (ask_aligned - bid_rates) / mid
    return spread

Weights

equal_weights

equal_weights(names: list[str]) -> pd.Series

Build a 1/N weight vector for the given asset names.

Parameters:

Name Type Description Default
names list[str]

The asset names to weight equally.

required

Returns:

Type Description
Series

A Series of weights indexed by names, each equal to 1/len(names).

Raises:

Type Description
ValueError

If names is empty.

Source code in src/portpy/core/weights.py
def equal_weights(names: list[str]) -> pd.Series:
    """
    Build a 1/N weight vector for the given asset names.

    Args:
        names: The asset names to weight equally.

    Returns:
        A Series of weights indexed by `names`, each equal to 1/len(names).

    Raises:
        ValueError: If `names` is empty.
    """
    n = len(names)
    if n == 0:
        raise ValueError("Cannot build weights for zero assets.")
    return pd.Series(np.full(n, 1.0 / n), index=list(names), name="weight")

normalize_weights

normalize_weights(
    weights: Series | ndarray | dict | list,
    names: list[str] | None = None,
    allow_negative: bool = True,
    method: Literal["net", "gross"] = "net",
) -> pd.Series

Validate a weights input and rescale it to a unit-exposure book.

Parameters:

Name Type Description Default
weights Series | ndarray | dict | list

A Series/dict keyed by asset name, or a plain array/list aligned with names.

required
names list[str] | None

Required asset order when weights isn't already labeled; also used to check that every asset has a weight.

None
allow_negative bool

If False, raise when any weight is negative (e.g. for long-only optimizers).

True
method Literal['net', 'gross']

"net" divides by weights.sum() (the usual convention). "gross" divides by weights.abs().sum() instead, for long/short books.

'net'

Returns:

Type Description
Series

A Series of weights indexed by names (in that order) when names is

Series

given. Sums to 1.0 under "net"; under "gross", .abs().sum() is 1.0.

Raises:

Type Description
TypeError

If weights isn't a Series, dict, list, tuple, or ndarray.

ValueError

If names is required but missing, if any asset is missing a weight, if any weight is NaN, if allow_negative is False and a weight is negative, or if the relevant exposure (net or gross) is zero.

Source code in src/portpy/core/weights.py
def normalize_weights(
    weights: pd.Series | np.ndarray | dict | list,
    names: list[str] | None = None,
    allow_negative: bool = True,
    method: Literal["net", "gross"] = "net",
) -> pd.Series:
    """
    Validate a weights input and rescale it to a unit-exposure book.

    Args:
        weights: A Series/dict keyed by asset name, or a plain array/list aligned
            with `names`.
        names: Required asset order when `weights` isn't already labeled; also used
            to check that every asset has a weight.
        allow_negative: If False, raise when any weight is negative (e.g. for
            long-only optimizers).
        method: "net" divides by `weights.sum()` (the usual convention). "gross"
            divides by `weights.abs().sum()` instead, for long/short books.

    Returns:
        A Series of weights indexed by `names` (in that order) when `names` is
        given. Sums to 1.0 under "net"; under "gross", `.abs().sum()` is 1.0.

    Raises:
        TypeError: If `weights` isn't a Series, dict, list, tuple, or ndarray.
        ValueError: If `names` is required but missing, if any asset is missing
            a weight, if any weight is NaN, if `allow_negative` is False and a
            weight is negative, or if the relevant exposure (net or gross) is zero.
    """
    if isinstance(weights, dict):
        weights = pd.Series(weights, dtype=float)
    elif isinstance(weights, pd.Series):
        weights = weights.astype(float)
    elif isinstance(weights, (list, tuple, np.ndarray)):
        if names is None:
            raise ValueError("`names` is required when `weights` is a list/array.")
        arr = np.asarray(weights, dtype=float)
        if len(arr) != len(names):
            raise ValueError(f"weights has {len(arr)} entries but {len(names)} names were given.")
        weights = pd.Series(arr, index=list(names))
    else:
        raise TypeError(f"Unsupported weights type: {type(weights).__name__}")

    if names is not None:
        missing = set(names) - set(weights.index)
        if missing:
            raise ValueError(f"Weights are missing for assets: {sorted(missing)}")
        weights = weights.reindex(list(names))

    if weights.isna().any():
        raise ValueError("Weights contain NaN values.")
    if not allow_negative and (weights < 0).any():
        raise ValueError("Negative weights are not allowed here (long-only constraint).")

    if method == "gross":
        denom = weights.abs().sum()
    elif method == "net":
        denom = weights.sum()
    else:
        raise ValueError(f"method must be 'net' or 'gross', got {method!r}.")
    if np.isclose(denom, 0.0):
        raise ValueError(f"Weights' {method} exposure is zero; cannot normalize.")
    return (weights / denom).rename("weight")