Inline Uploaded Exposures#

Use an inline uploaded exposure group when you have your own factor exposures and want to see them as factors in a model on an existing dataset straight away, without building or updating a risk dataset. The group is resolved while the report runs, so the loop is “re-upload, rerun” instead of “re-upload, rebuild the dataset, rerun”.

import numpy as np
import polars as pl

from bayesline.api.equity import (
    CategoricalExposureGroupSettings,
    CategoricalFilterSettings,
    ContinuousExposureGroupSettings,
    ExposureSettings,
    FactorRiskModelSettings,
    InlineUploadedExposureGroupSettings,
    ModelConstructionSettings,
    UniverseSettings,
)
from bayesline.apiclient import BayeslineApiClient
bln = BayeslineApiClient.new_client(
    endpoint="https://[ENDPOINT]",
    api_key="[API-KEY]",
)

Building the exposures table#

We need a value per asset per date, on the assets and the trading days of the dataset we are going to model. The cheapest way to get that grid is to ask the exposures API for the dataset’s own style exposures: the first column is the date, the second is the asset id, and the rest are the served style factors.

dataset = "bayesline/Bayesline-US-500-1y"
exposures_api = bln.equity.exposures.load(
    ExposureSettings(
        exposures=[ContinuousExposureGroupSettings(hierarchy="style")]
    ).with_dataset(dataset)
)

style_df = exposures_api.get(
    UniverseSettings(), standardize_universe=None, filter_tradedays=True
)
style_df.head()
shape: (5, 9)
datebayesidstyle.Dividendstyle.Growthstyle.Leveragestyle.Momentumstyle.Sizestyle.Valuestyle.Volatility
datestrf32f32f32f32f32f32f32
2025-03-31"IC006CA2E0"0.3894040.7685550.2615970.8442382.708984-0.600098-0.182373
2025-03-31"IC0121F541"0.0271151.1252.0356450.840821.805176-1.351562-0.601644
2025-03-31"IC012D373B"0.1212160.5566410.988037-0.6059572.166504-0.8818360.357689
2025-03-31"IC01430182"-0.1837160.878906-0.5524440.0493161.960449-1.1513670.42863
2025-03-31"IC015A481B"-0.1387941.16406250.22403-0.6157232.044434-0.647461-0.062337

We build two factors on that grid, so the notebook stays reproducible without any outside data:

  • value_x_momentum is the product of the served Value and Momentum style exposures. It is deterministic and it is not a linear combination of the style block, so it survives next to it in the same regression.

  • random_signal is a seeded draw from a standard normal, standing in for a proprietary signal.

The uploader takes the long format: one row per date, asset_id, asset_id_type, factor_group, factor, with the value in exposure. Those six columns are the primary key plus the value, and anything else we hand it is dropped. We upload bayesid ids because that is what the exposures API served us.

interaction_df = style_df.select(
    "date",
    pl.col("bayesid").alias("asset_id"),
    pl.lit("bayesid").alias("asset_id_type"),
    pl.lit("my_signals").alias("factor_group"),
    pl.lit("value_x_momentum").alias("factor"),
    (pl.col("style.Value") * pl.col("style.Momentum")).cast(pl.Float32).alias("exposure"),
)
interaction_df.head()
shape: (5, 6)
dateasset_idasset_id_typefactor_groupfactorexposure
datestrstrstrstrf32
2025-03-31"IC006CA2E0""bayesid""my_signals""value_x_momentum"-0.506625
2025-03-31"IC0121F541""bayesid""my_signals""value_x_momentum"-1.136421
2025-03-31"IC012D373B""bayesid""my_signals""value_x_momentum"0.534355
2025-03-31"IC01430182""bayesid""my_signals""value_x_momentum"-0.056781
2025-03-31"IC015A481B""bayesid""my_signals""value_x_momentum"0.398656
rng = np.random.default_rng(42)

random_df = style_df.select(
    "date",
    pl.col("bayesid").alias("asset_id"),
    pl.lit("bayesid").alias("asset_id_type"),
    pl.lit("my_signals").alias("factor_group"),
    pl.lit("random_signal").alias("factor"),
    pl.Series("exposure", rng.standard_normal(style_df.height), dtype=pl.Float32),
)
random_df.head()
shape: (5, 6)
dateasset_idasset_id_typefactor_groupfactorexposure
datestrstrstrstrf32
2025-03-31"IC006CA2E0""bayesid""my_signals""random_signal"0.304717
2025-03-31"IC0121F541""bayesid""my_signals""random_signal"-1.039984
2025-03-31"IC012D373B""bayesid""my_signals""random_signal"0.750451
2025-03-31"IC01430182""bayesid""my_signals""random_signal"0.940565
2025-03-31"IC015A481B""bayesid""my_signals""random_signal"-1.951035
upload_df = pl.concat([interaction_df, random_df])
upload_df.head()
shape: (5, 6)
dateasset_idasset_id_typefactor_groupfactorexposure
datestrstrstrstrf32
2025-03-31"IC006CA2E0""bayesid""my_signals""value_x_momentum"-0.506625
2025-03-31"IC0121F541""bayesid""my_signals""value_x_momentum"-1.136421
2025-03-31"IC012D373B""bayesid""my_signals""value_x_momentum"0.534355
2025-03-31"IC01430182""bayesid""my_signals""value_x_momentum"-0.056781
2025-03-31"IC015A481B""bayesid""my_signals""value_x_momentum"0.398656

Uploading#

Both factors go into one exposures upload under one factor_group, which is the unit an inline group reads. fast_commit skips the staging step, which is what we want for a dataframe we already hold in memory. See the Uploaders Tutorial for staging and for the other commit modes.

exposure_dataset_name = "My-Inline-Signals"
exposure_uploaders = bln.equity.uploaders.get_data_type("exposures")
my_signals = exposure_uploaders.create_or_replace_dataset(exposure_dataset_name)

my_signals.fast_commit(upload_df, mode="append")
UploadCommitResult(version=1, committed_names=[])
committed_df = my_signals.get_data().collect()

print("upload:", exposure_dataset_name)
print("rows:", committed_df.height)
print("dates:", committed_df["date"].min(), "to", committed_df["date"].max())
upload: My-Inline-Signals
rows: 250706
dates: 2025-03-31 to 2026-03-31
my_signals.get_data_detail_summary()
shape: (252, 6)
daten_assetsmin_exposuremax_exposuremean_exposurestd_exposure
datei64f32f32f64f64
2025-03-31500-3.31252.9140625-0.12750.897733
2025-04-01500-3.6484383.1796875-0.1330880.922186
2025-04-02500-3.3222662.808594-0.0939450.919222
2025-04-03500-3.2480472.9140625-0.1758630.898337
2025-04-04500-3.0644532.857422-0.0858560.896702
2026-03-25496-3.1054693.5527340.0616720.988378
2026-03-26496-3.1601563.7070310.0608410.981063
2026-03-27496-3.4277343.5039060.0583090.963608
2026-03-30496-3.0410163.5058590.0980440.938905
2026-03-31496-2.8515623.4726560.0654790.94985

Referencing the upload from a model#

InlineUploadedExposureGroupSettings names the upload and the factor group inside it, and sits in ExposureSettings.exposures next to the dataset’s own groups. Everything else about the model is standard.

The group’s knobs are all off by default, on the assumption that the values you uploaded are the values you want used.

  • forward_fill carries the last known value across dates the upload has no row for, gated by the days the asset is in the modeling universe.

  • gaussianize converts the values to standard-normal ranks; gaussianize_maintain_zeros keeps zeros at zero while doing so.

  • fill_miss fills in missing exposures.

  • standardize_method="equal_weighted" z-scores the values per date against the mean and standard deviation of the estimation universe; "none" passes them through untouched.

Assets in the upload that are not part of the risk dataset are dropped with a warning when the report is built, rather than failing the run.

factorriskmodel_settings = FactorRiskModelSettings(
    universe=UniverseSettings(),
    exposures=ExposureSettings(
        exposures=[
            ContinuousExposureGroupSettings(hierarchy="market"),
            CategoricalExposureGroupSettings(hierarchy="trbc"),
            ContinuousExposureGroupSettings(hierarchy="style"),
            InlineUploadedExposureGroupSettings(
                exposure_source=exposure_dataset_name,
                factor_group="my_signals",
                standardize_method="none",
            ),
        ]
    ),
    modelconstruction=ModelConstructionSettings(
        weights="InvIdioVar",
        estimation_universe=UniverseSettings(
            categorical_filters=[
                CategoricalFilterSettings(hierarchy="estimation_universe")
            ],
        ),
        return_clip_bounds=(None, None),
        zero_sum_constraints={"trbc": "mcap_weighted"},
    ),
)
risk_model = bln.equity.riskmodels.load(
    factorriskmodel_settings.with_dataset(dataset)
).get_model()

The group behaves like any other dense group#

The upload’s factor group shows up alongside the dataset’s groups, and its factors are named my_signals.value_x_momentum and my_signals.random_signal after the factor_group value in the upload. Being dense, the group can also be a target or source of a net_of projection; the tutorial_exposure_orthogonalization notebook covers that in full.

risk_model.factors()
{'market': ['Market'],
 'my_signals': ['random_signal', 'value_x_momentum'],
 'trbc': ['Academic & Educational Services',
  'Basic Materials',
  'Consumer Cyclicals',
  'Consumer Non-Cyclicals',
  'Energy',
  'Financials',
  'Government Activity',
  'Healthcare',
  'Industrials',
  'Institutions, Associations & Organizations',
  'Real Estate',
  'Technology',
  'Utilities'],
 'style': ['Dividend',
  'Growth',
  'Leverage',
  'Momentum',
  'Size',
  'Value',
  'Volatility']}
risk_model.exposures().select(
    "date",
    "bayesid",
    "market.Market",
    "my_signals.value_x_momentum",
    "my_signals.random_signal",
).head()
shape: (5, 5)
datebayesidmarket.Marketmy_signals.value_x_momentummy_signals.random_signal
datestrf32f32f32
2025-03-31"IC006CA2E0"1.0-0.5068360.3046875
2025-03-31"IC0121F541"1.0-1.136719-1.040039
2025-03-31"IC012D373B"1.00.534180.750488
2025-03-31"IC01430182"1.0-0.0567930.94043
2025-03-31"IC015A481B"1.00.398682-1.951172
risk_model.fret().select(
    "date",
    "market.Market",
    "my_signals.value_x_momentum",
    "my_signals.random_signal",
).head()
shape: (5, 4)
datemarket.Marketmy_signals.value_x_momentummy_signals.random_signal
datef32f32f32
2025-03-310.00.00.0
2025-04-010.0074040.00077-0.000646
2025-04-020.022120.001807-0.000461
2025-04-03-0.075951-0.0029740.003802
2025-04-04-0.0307530.005609-0.001347
risk_model.t_stats().select(
    "date",
    "market.Market",
    "my_signals.value_x_momentum",
    "my_signals.random_signal",
).head()
shape: (5, 4)
datemarket.Marketmy_signals.value_x_momentummy_signals.random_signal
datef32f32f32
2025-03-31NaNNaNNaN
2025-04-012.4336050.841934-1.57795
2025-04-027.8225112.10416-1.582667
2025-04-03-7.677681-1.0077273.671594
2025-04-04-4.0164562.335344-1.626977

Checking the served exposures against the upload#

With standardize_method="none" and every other knob off, the served exposures are the values we uploaded.

uploaded_wide = upload_df.pivot("factor", index=["date", "asset_id"], values="exposure")
uploaded_wide.head()
shape: (5, 4)
dateasset_idvalue_x_momentumrandom_signal
datestrf32f32
2025-03-31"IC006CA2E0"-0.5066250.304717
2025-03-31"IC0121F541"-1.136421-1.039984
2025-03-31"IC012D373B"0.5343550.750451
2025-03-31"IC01430182"-0.0567810.940565
2025-03-31"IC015A481B"0.398656-1.951035
check_df = risk_model.exposures().select(
    "date", "bayesid", "my_signals.value_x_momentum", "my_signals.random_signal"
).join(
    uploaded_wide,
    left_on=["date", "bayesid"],
    right_on=["date", "asset_id"],
    how="left",
)
check_df.head()
shape: (5, 6)
datebayesidmy_signals.value_x_momentummy_signals.random_signalvalue_x_momentumrandom_signal
datestrf32f32f32f32
2025-03-31"IC006CA2E0"-0.5068360.3046875-0.5066250.304717
2025-03-31"IC0121F541"-1.136719-1.040039-1.136421-1.039984
2025-03-31"IC012D373B"0.534180.7504880.5343550.750451
2025-03-31"IC01430182"-0.0567930.94043-0.0567810.940565
2025-03-31"IC015A481B"0.398682-1.9511720.398656-1.951035
max_diff_interaction = (
    check_df["my_signals.value_x_momentum"] - check_df["value_x_momentum"]
).abs().max()

max_diff_random = (
    check_df["my_signals.random_signal"] - check_df["random_signal"]
).abs().max()

print("served rows:", check_df.height)
print("served rows the upload does not cover (== 0):", check_df["value_x_momentum"].null_count())
print("max |served - uploaded| value_x_momentum (~= 0):", max_diff_interaction)
print("max |served - uploaded| random_signal (~= 0):", max_diff_random)
served rows: 125353
served rows the upload does not cover (== 0): 0
max |served - uploaded| value_x_momentum (~= 0): 0.0009765625
max |served - uploaded| random_signal (~= 0): 0.0019207000732421875
assert check_df["value_x_momentum"].null_count() == 0
assert max_diff_interaction < 0.05
assert max_diff_random < 0.05

Housekeeping#

my_signals.destroy()