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:

  1. What happens if an existing child portfolio changes one asset position?

  2. 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",))
shape: (3, 1)
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")
shape: (38, 6)
dateportfolio_idasset_idnavweightcase
datestrstrf32f32str
2026-01-05"whatif-parent:whatif-existing-…"ICA17F00B9"72.00.58318"asset scenario"
2026-01-05"whatif-parent:whatif-existing-…"ICA17F00B9"48.5428810.48541"base"
2026-01-06"whatif-parent:whatif-existing-…"ICA17F00B9"71.4996030.580162"asset scenario"
2026-01-06"whatif-parent:whatif-existing-…"ICA17F00B9"48.2055170.482312"base"
2026-01-07"whatif-parent:whatif-existing-…"ICA17F00B9"73.2373730.583531"asset scenario"
2026-01-28"whatif-parent:whatif-existing-…"ICA17F00B9"51.5286870.495776"base"
2026-01-29"whatif-parent:whatif-existing-…"ICA17F00B9"76.9381480.609332"asset scenario"
2026-01-29"whatif-parent:whatif-existing-…"ICA17F00B9"51.8722150.512569"base"
2026-01-30"whatif-parent:whatif-existing-…"ICA17F00B9"76.8812710.610901"asset scenario"
2026-01-30"whatif-parent:whatif-existing-…"ICA17F00B9"51.833870.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")
shape: (7, 2)
portfolio_idcase
strstr
"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",
)
shape: (19, 5)
dateportfolio_idinput_asset_idnavweight
datestrstrf32f32
2026-01-05"whatif-parent:whatif-new-child""67066G10"25.0009960.2
2026-01-06"whatif-parent:whatif-new-child""67066G10"24.8840480.199342
2026-01-07"whatif-parent:whatif-new-child""67066G10"25.1325680.198238
2026-01-08"whatif-parent:whatif-new-child""67066G10"24.5916650.195206
2026-01-09"whatif-parent:whatif-new-child""67066G10"24.5677450.194223
2026-01-26"whatif-parent:whatif-new-child""67066G10"24.7817150.195127
2026-01-27"whatif-parent:whatif-new-child""67066G10"25.0541570.195165
2026-01-28"whatif-parent:whatif-new-child""67066G10"25.4528540.196717
2026-01-29"whatif-parent:whatif-new-child""67066G10"25.5844230.201794
2026-01-30"whatif-parent:whatif-new-child""67066G10"25.4010260.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()