"""Containers for aligned cross-sectional asset data."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
from skfolio.containers._asset_panel._base import _BaseAssetPanel
from skfolio.containers._asset_panel._fields import BaseField
from skfolio.containers._asset_panel._utils import (
_as_field,
_compose_observation_selectors,
_normalize_field_names,
_normalize_positional_selector,
_raise_if_raw_replaces_typed_field,
_selector_length,
_validate_field_against_axes,
_validate_field_name,
)
from skfolio.typing import AnyArray, ArrayLike, BoolArray, IntArray, StrArray
if TYPE_CHECKING:
from skfolio.containers._asset_panel._panel import AssetPanel
__all__ = ["AssetPanelView"]
[docs]
@dataclass(slots=True)
class AssetPanelView(_BaseAssetPanel):
"""Observation-sliced view into an `AssetPanel`.
A view stores an observation selector, a reference to its owner and optional
view-local fields. Owner field arrays are sliced lazily on access through `fields`,
`__getitem__` and `get_field`, so building a view never copies owner field data.
When the selector is a slice, all access remains zero-copy and composing nested
views produces another zero-copy slice. When the selector is an integer or boolean
array, NumPy fancy indexing is applied on access and the resulting arrays may be
copies.
New fields can also be added directly to a view. These view-local fields are useful
for derived data that only belongs to one slice (e.g. values computed for a
cross-validation fold). They are stored on the view, do not modify the owner panel,
and must have shape (n_view_observations, n_assets, ...).
Parameters
----------
owner : AssetPanel
Panel that owns the underlying arrays.
observation_selector : slice or ndarray of integers, optional
Selector applied to the owner observation axis. Slices preserve zero-copy
semantics. Integer arrays follow NumPy fancy-indexing semantics on access.
The default (`None`) selects all observations.
_local_fields : dict[str, BaseField], optional
View-local fields. This argument is for internal use. Use `view[name] = value`
to add local fields.
See Also
--------
AssetPanel : Owning container.
AssetPanel.isel : Returns a view for observation-only selections.
AssetPanel.sel : Label-based equivalent of `AssetPanel.isel`.
"""
owner: AssetPanel
observation_selector: slice | IntArray | None = None
_local_fields: dict[str, BaseField] | None = None
def __post_init__(self) -> None:
if self.owner is None:
raise ValueError("AssetPanelView requires an owner.")
self.observation_selector = _normalize_positional_selector(
self.owner.n_observations,
self.observation_selector,
)
if self._local_fields is None:
self._local_fields = {}
for name, field in self._local_fields.items():
_validate_field_name(name)
self._validate_field(name, field)
def __len__(self) -> int:
"""Return the number of observations in the view."""
return self.n_observations
def __contains__(self, name: str) -> bool:
"""Check whether a local or owner field exists."""
return name in self._local_fields or name in self.owner.fields
def __getitem__(self, key: Any) -> AnyArray | AssetPanelView:
"""Return field values or a nested observation view.
Parameters
----------
key : str, int, slice, or array-like
If `key` is a string, return the field values resolved through local fields
and the owner panel. Otherwise, interpret `key` as an observation selector
relative to the current view.
Returns
-------
values or view : ndarray or AssetPanelView
For a string key, the field values sliced to the current view. For an
observation selector, a nested `AssetPanelView` whose selector is composed
with the current view selector.
Notes
-----
Selector composition keeps slice-only chains zero-copy. When a selector is an
integer or boolean array, NumPy fancy indexing is applied on access and may copy.
"""
if isinstance(key, tuple):
raise TypeError(
"AssetPanelView supports field access by name or one-dimensional "
"observation selectors."
)
if isinstance(key, str):
return self.get_field(key).values
inner_selector = _normalize_positional_selector(self.n_observations, key)
composed_selector = _compose_observation_selectors(
outer_selector=self.observation_selector,
inner_selector=inner_selector,
total_length=self.owner.n_observations,
)
local_fields = {
name: field.with_values(field.values[inner_selector])
for name, field in self._local_fields.items()
}
return AssetPanelView(
owner=self.owner,
observation_selector=composed_selector,
_local_fields=local_fields,
)
def __setitem__(self, name: str, value: BaseField | ArrayLike) -> None:
"""Add or replace a view-local field.
Raw arrays are converted to `Field2D`. Replacing an existing `FieldCategorical`
or `Field3D` requires an explicit field object so metadata is not discarded
accidentally.
Parameters
----------
name : str
Local field name.
value : BaseField or array-like
Field object or raw 2D array with first two axes
(n_view_observations, n_assets).
"""
_validate_field_name(name)
if name in self:
existing_field = (
self._local_fields[name]
if name in self._local_fields
else self.owner.fields[name]
)
_raise_if_raw_replaces_typed_field(
name=name, value=value, existing_field=existing_field
)
field = _as_field(value)
self._validate_field(name, field)
self._local_fields[name] = field
def __delitem__(self, name: str) -> None:
"""Delete a view-local field.
Owner fields cannot be deleted through a view.
Parameters
----------
name : str
Name of the local field to delete.
"""
if name not in self._local_fields:
raise KeyError(
f"Field '{name}' is not a local field. Owner fields cannot be "
"deleted from a view."
)
del self._local_fields[name]
def __repr__(self) -> str:
return (
f"AssetPanelView(n_observations={self.n_observations}, "
f"n_assets={self.n_assets}, n_fields={len(self.keys())})"
)
@property
def n_observations(self) -> int:
"""Number of observations in the view."""
return _selector_length(self.owner.n_observations, self.observation_selector)
@property
def n_assets(self) -> int:
"""Number of assets."""
return self.owner.n_assets
@property
def observations(self) -> AnyArray:
"""Observation labels selected by the view."""
return self.owner.observations[self.observation_selector]
@property
def asset_names(self) -> StrArray:
"""Asset labels."""
return self.owner.asset_names
@property
def active_mask(self) -> BoolArray:
"""Active mask selected by the view."""
return self.owner.active_mask[self.observation_selector]
@property
def estimation_mask(self) -> BoolArray:
"""Estimation mask selected by the view."""
return self.owner.estimation_mask[self.observation_selector]
@property
def fields(self) -> Mapping[str, BaseField]:
"""Lazy mapping of field objects with view-sized values.
Field objects are constructed on access and reuse the owner array sliced by
`observation_selector`. Iterating through this mapping does not materialize
sliced arrays for fields that are not accessed.
"""
return _ViewFieldMapping(self)
[docs]
def keys(self) -> Iterable[str]:
"""Return field names visible from the view.
Returns
-------
names : list of str
Union of view-local field names and owner field names. Local fields shadow
owner fields with the same name. Owner field order is preserved and
local-only fields are appended in insertion order.
"""
local_only = [
name for name in self._local_fields if name not in self.owner.fields
]
return [*self.owner.fields.keys(), *local_only]
[docs]
def get_field(self, name: str) -> BaseField:
"""Return a local field or an owner field sliced to the view.
Parameters
----------
name : str
Field name.
Returns
-------
field : BaseField
Field object with first two axes matching the view.
"""
if name in self._local_fields:
return self._local_fields[name]
field = self.owner.fields[name]
return field.with_values(field.values[self.observation_selector])
[docs]
def copy(self, *, deep: bool = False, copy_owner: bool = True) -> AssetPanelView:
"""Return a copy of the view.
Parameters
----------
deep : bool, default=False
If `True`, copy local field arrays and the observation selector when
it is an ndarray.
copy_owner : bool, default=True
If `True`, copy the owner panel. If `False`, the copied view points
to the same owner.
Returns
-------
view : AssetPanelView
Copied view.
"""
owner = self.owner.copy(deep=deep) if copy_owner else self.owner
selector = (
self.observation_selector.copy()
if deep and isinstance(self.observation_selector, np.ndarray)
else self.observation_selector
)
local_fields = {
name: field.copy(deep=deep) for name, field in self._local_fields.items()
}
return AssetPanelView(
owner=owner,
observation_selector=selector,
_local_fields=local_fields,
)
[docs]
def to_panel(
self,
*,
fields: str | Iterable[str] | None = None,
deep: bool = True,
) -> AssetPanel:
"""Return a new `AssetPanel` for the view's selected observations.
Parameters
----------
fields : str, iterable of str, or None, optional
Field names to include. If `None`, all visible fields are included.
deep : bool, default=True
If `True`, copy field arrays and label arrays. If `False`, field arrays and
labels may share memory with the view source. Masks are always copied so the
returned panel owns independent lockable mask arrays.
Returns
-------
panel : AssetPanel
Panel containing only the view's observations and selected fields.
"""
from skfolio.containers._asset_panel._panel import AssetPanel
field_names = _normalize_field_names(self.keys(), fields)
panel_fields = {
name: self.get_field(name).copy(deep=deep) for name in field_names
}
return AssetPanel(
fields=panel_fields,
observations=self.observations.copy() if deep else self.observations,
asset_names=self.asset_names.copy() if deep else self.asset_names,
active_mask=self.active_mask.copy(),
estimation_mask=self.estimation_mask.copy(),
_validate_on_init=False,
)
def _validate_field(self, name: str, field: BaseField) -> None:
"""Validate a local field against view axes and masks."""
_validate_field_against_axes(
name=name,
field=field,
expected_shape=(self.n_observations, self.n_assets),
active_mask=self.active_mask,
)
class _ViewFieldMapping(Mapping[str, BaseField]):
"""Lazy mapping exposing field objects sized to an `AssetPanelView`."""
def __init__(self, view: AssetPanelView) -> None:
self._view = view
def __getitem__(self, name: str) -> BaseField:
return self._view.get_field(name)
def __iter__(self) -> Iterable[str]:
return iter(self._view.keys())
def __len__(self) -> int:
return len(self._view.keys())