What-if scenarios#
Prerequisites
Portfolios API Tutorial
Portfolio Hierarchies Tutorial
A scenario changes a report input without changing the uploaded holdings or the saved report settings. Applying one produces a new accessor; the original report remains available for comparison.
This tutorial covers two portfolio what-if questions:
What happens if an existing child portfolio changes one asset position?
What happens if a fund of funds starts holding a portfolio that was previously available only as a standalone portfolio?
Imports and setup#
import datetime as dt
import polars as pl
from bayesline.apiclient import BayeslineApiClient
from bayesline.api.equity import (
HoldingsOverrideWindow,
OverrideUnit,
PortfolioHierarchySettings,
PortfolioHoldingsReportSettings,
PortfolioHoldingsScenario,
PortfolioOrganizerSettings,
PortfolioOverride,
)
bln = BayeslineApiClient.new_client(
endpoint="https://[ENDPOINT]",
api_key="[API-KEY]",
)
dataset_name = "bayesline/Bayesline-US-All-1y"
start_date = dt.date(2026, 1, 5)
end_date = dt.date(2026, 1, 30)
Create a small fund-of-funds example#
The baseline parent holds whatif-existing-child plus one security directly.
whatif-new-child is uploaded separately and selected by the organizer, but it is
not held by the parent. Keeping it in a second upload also demonstrates that a
scenario edge is not restricted to portfolios from the same upload.
uploader = bln.equity.uploaders.get_data_type("portfolios")
parent_source = uploader.create_or_replace_dataset("whatif-parent-source")
child_source = uploader.create_or_replace_dataset("whatif-child-source")
parent_holdings = pl.DataFrame(
{
"portfolio_id": [
"whatif-parent",
"whatif-parent",
"whatif-parent",
"whatif-parent",
"whatif-existing-child",
"whatif-existing-child",
"whatif-existing-child",
"whatif-existing-child",
],
"asset_id": [
"whatif-existing-child",
"whatif-existing-child",
"67066G10",
"67066G10",
"02079K305",
"02079K305",
"2588173",
"2588173",
],
"asset_id_type": [
"portfolio_id",
"portfolio_id",
"cusip8",
"cusip8",
"cusip9",
"cusip9",
"sedol7",
"sedol7",
],
"date": [
dt.date(2026, 1, 1),
dt.date(2026, 1, 31),
dt.date(2026, 1, 1),
dt.date(2026, 1, 31),
dt.date(2026, 1, 1),
dt.date(2026, 1, 31),
dt.date(2026, 1, 1),
dt.date(2026, 1, 31),
],
"currency": [None] * 8,
"share_qty": [None] * 8,
"nav": [80.0, 80.0, 20.0, 20.0, 60.0, 60.0, 40.0, 40.0],
}
).with_columns(
pl.col("currency").cast(pl.String),
pl.col("share_qty").cast(pl.Float64),
)
new_child_holdings = pl.DataFrame(
{
"portfolio_id": ["whatif-new-child"] * 4,
"asset_id": ["85371710", "85371710", "67066G10", "67066G10"],
"asset_id_type": ["cusip8"] * 4,
"date": [
dt.date(2026, 1, 1),
dt.date(2026, 1, 31),
dt.date(2026, 1, 1),
dt.date(2026, 1, 31),
],
"currency": [None] * 4,
"share_qty": [None] * 4,
"nav": [50.0, 50.0, 50.0, 50.0],
}
).with_columns(
pl.col("currency").cast(pl.String),
pl.col("share_qty").cast(pl.Float64),
)
parent_source.fast_commit(parent_holdings, mode="append")
child_source.fast_commit(new_child_holdings, mode="append")
UploadCommitResult(version=1, committed_names=[])
The organizer selects all three source portfolios. The hierarchy exposes the
parent and the prospective child as roots. Auto-decomposition expands the
parent’s baseline child and adds a {REST} row for its direct security holding.
organizer = PortfolioOrganizerSettings(
enabled_portfolios={
"whatif-parent": "whatif-parent-source",
"whatif-existing-child": "whatif-parent-source",
"whatif-new-child": "whatif-child-source",
}
)
hierarchy = PortfolioHierarchySettings(
portfolio_schema=organizer,
portfolio_ids=["whatif-parent", "whatif-new-child"],
benchmark_ids=[None, None],
auto_decompose_levels=["fund", "child"],
)
report_settings = PortfolioHoldingsReportSettings(
portfolio_hierarchy_settings=hierarchy,
currency="USD",
)
report_engine = bln.equity.reports.load(report_settings.with_dataset(dataset_name))
base = report_engine.calculate(
portfolio_names=None,
start_date=start_date,
end_date=end_date,
)
base.accessor.get_level_values(("portfolio_id",))
| portfolio_id |
|---|
| str |
| "whatif-parent:whatif-existing-… |
| "whatif-parent:{REST}" |
| "whatif-new-child" |
Asset holding scenario#
Scenario asset targets use the resolved asset identifier. We obtain it from the report instead of assuming which identifier the dataset uses internally.
base_holdings = base.holdings()
asset_id = (
base_holdings.filter(pl.col("input_asset_id") == "02079K305")
.get_column("asset_id")
.first()
)
assert isinstance(asset_id, str)
asset_id
'ICA17F00B9'
asset_scenario = PortfolioHoldingsScenario(
portfolios={
"whatif-existing-child": PortfolioOverride(
windows=[
HoldingsOverrideWindow(
start_date=start_date,
end_date=end_date,
overrides={asset_id: 90.0},
)
]
)
}
)
asset_case = base.with_scenario(asset_scenario)
The override is applied to the child portfolio and automatically cascades into the parent. The base and scenario accessors can be queried independently.
base_asset = base.accessor.get_data(
[
("portfolio_id", "whatif-parent:whatif-existing-child"),
("asset_id", asset_id),
],
expand=("date",),
value_cols=("nav", "weight"),
)
scenario_asset = asset_case.accessor.get_data(
[
("portfolio_id", "whatif-parent:whatif-existing-child"),
("asset_id", asset_id),
],
expand=("date",),
value_cols=("nav", "weight"),
)
pl.concat(
[
base_asset.with_columns(pl.lit("base").alias("case")),
scenario_asset.with_columns(pl.lit("asset scenario").alias("case")),
]
).sort("date", "case")
| date | portfolio_id | asset_id | nav | weight | case |
|---|---|---|---|---|---|
| date | str | str | f32 | f32 | str |
| 2026-01-05 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 72.0 | 0.58318 | "asset scenario" |
| 2026-01-05 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 48.542881 | 0.48541 | "base" |
| 2026-01-06 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 71.499603 | 0.580162 | "asset scenario" |
| 2026-01-06 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 48.205517 | 0.482312 | "base" |
| 2026-01-07 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 73.237373 | 0.583531 | "asset scenario" |
| … | … | … | … | … | … |
| 2026-01-28 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 51.528687 | 0.495776 | "base" |
| 2026-01-29 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 76.938148 | 0.609332 | "asset scenario" |
| 2026-01-29 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 51.872215 | 0.512569 | "base" |
| 2026-01-30 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 76.881271 | 0.610901 | "asset scenario" |
| 2026-01-30 | "whatif-parent:whatif-existing-… | "ICA17F00B9" | 51.83387 | 0.514216 | "base" |
Add a child portfolio to the fund of funds#
The new child is already selected by the organizer and therefore already exists
on the report’s source-portfolio axis. It is not a child of whatif-parent in
the uploaded holdings. Setting a nonzero override creates that scenario-only
edge. Here we express the new parent-child holding as 25% of the parent’s NAV.
fof_scenario = PortfolioHoldingsScenario(
portfolios={
"whatif-parent": PortfolioOverride(
windows=[
HoldingsOverrideWindow(
start_date=start_date,
end_date=end_date,
overrides={"whatif-new-child": 0.25},
override_unit=OverrideUnit.WEIGHT,
)
]
)
}
)
fof_case = base.with_scenario(fof_scenario)
Applying the scenario atomically adds the derived path to the new accessor’s portfolio hierarchy. The original accessor remains unchanged and keeps its original portfolio-axis hash.
base_portfolios = base.accessor.get_level_values(("portfolio_id",))
scenario_portfolios = fof_case.accessor.get_level_values(("portfolio_id",))
new_path = "whatif-parent:whatif-new-child"
assert new_path not in base_portfolios.get_column("portfolio_id")
assert new_path in scenario_portfolios.get_column("portfolio_id")
assert (
base.accessor.axes_hashes["portfolio"]
!= fof_case.accessor.axes_hashes["portfolio"]
)
pl.concat(
[
base_portfolios.with_columns(pl.lit("base").alias("case")),
scenario_portfolios.with_columns(pl.lit("FoF scenario").alias("case")),
]
).sort("portfolio_id", "case")
| portfolio_id | case |
|---|---|
| str | str |
| "whatif-new-child" | "FoF scenario" |
| "whatif-new-child" | "base" |
| "whatif-parent:whatif-existing-… | "FoF scenario" |
| "whatif-parent:whatif-existing-… | "base" |
| "whatif-parent:whatif-new-child" | "FoF scenario" |
| "whatif-parent:{REST}" | "FoF scenario" |
| "whatif-parent:{REST}" | "base" |
The new row behaves like any other portfolio path. We can drill into it using the standard accessor API; its holdings come from the already-loaded standalone child portfolio.
fof_case.accessor.get_data(
[("portfolio_id", new_path)],
expand=("date", "input_asset_id"),
value_cols=("nav", "weight"),
absent="drop",
)
| date | portfolio_id | input_asset_id | nav | weight |
|---|---|---|---|---|
| date | str | str | f32 | f32 |
| 2026-01-05 | "whatif-parent:whatif-new-child" | "67066G10" | 25.000996 | 0.2 |
| 2026-01-06 | "whatif-parent:whatif-new-child" | "67066G10" | 24.884048 | 0.199342 |
| 2026-01-07 | "whatif-parent:whatif-new-child" | "67066G10" | 25.132568 | 0.198238 |
| 2026-01-08 | "whatif-parent:whatif-new-child" | "67066G10" | 24.591665 | 0.195206 |
| 2026-01-09 | "whatif-parent:whatif-new-child" | "67066G10" | 24.567745 | 0.194223 |
| … | … | … | … | … |
| 2026-01-26 | "whatif-parent:whatif-new-child" | "67066G10" | 24.781715 | 0.195127 |
| 2026-01-27 | "whatif-parent:whatif-new-child" | "67066G10" | 25.054157 | 0.195165 |
| 2026-01-28 | "whatif-parent:whatif-new-child" | "67066G10" | 25.452854 | 0.196717 |
| 2026-01-29 | "whatif-parent:whatif-new-child" | "67066G10" | 25.584423 | 0.201794 |
| 2026-01-30 | "whatif-parent:whatif-new-child" | "67066G10" | 25.401026 | 0.201272 |
Any number of previously absent children can be added in the same scenario.
The hierarchy expansion, {REST} rewrite, and lookthrough compilation happen
together, leaving the base accessor and uploaded holdings untouched.
Housekeeping#
parent_source.destroy()
child_source.destroy()