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
¶
Calendar alignment¶
detect_frequency ¶
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
calendar_coverage_report ¶
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
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'
|
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
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 ( |
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
calculate_fx_spread ¶
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 |
required |
ask_rates
|
DataFrame
|
Same shape/columns/convention as |
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
|
DataFrame
|
a dimensionless spread fraction (0.001 = a 10 bps quoted spread), |
DataFrame
|
feed straight into |
DataFrame
|
after multiplying by 10,000. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
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
Weights¶
equal_weights ¶
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 |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/portpy/core/weights.py
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 |
required |
names
|
list[str] | None
|
Required asset order when |
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 |
'net'
|
Returns:
| Type | Description |
|---|---|
Series
|
A Series of weights indexed by |
Series
|
given. Sums to 1.0 under "net"; under "gross", |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |