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()
| date | bayesid | style.Dividend | style.Growth | style.Leverage | style.Momentum | style.Size | style.Value | style.Volatility |
|---|---|---|---|---|---|---|---|---|
| date | str | f32 | f32 | f32 | f32 | f32 | f32 | f32 |
| 2025-03-31 | "IC006CA2E0" | 0.389404 | 0.768555 | 0.261597 | 0.844238 | 2.708984 | -0.600098 | -0.182373 |
| 2025-03-31 | "IC0121F541" | 0.027115 | 1.125 | 2.035645 | 0.84082 | 1.805176 | -1.351562 | -0.601644 |
| 2025-03-31 | "IC012D373B" | 0.121216 | 0.556641 | 0.988037 | -0.605957 | 2.166504 | -0.881836 | 0.357689 |
| 2025-03-31 | "IC01430182" | -0.183716 | 0.878906 | -0.552444 | 0.049316 | 1.960449 | -1.151367 | 0.42863 |
| 2025-03-31 | "IC015A481B" | -0.138794 | 1.1640625 | 0.22403 | -0.615723 | 2.044434 | -0.647461 | -0.062337 |
We build two factors on that grid, so the notebook stays reproducible without any outside data:
value_x_momentumis 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_signalis 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()
| date | asset_id | asset_id_type | factor_group | factor | exposure |
|---|---|---|---|---|---|
| date | str | str | str | str | f32 |
| 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()
| date | asset_id | asset_id_type | factor_group | factor | exposure |
|---|---|---|---|---|---|
| date | str | str | str | str | f32 |
| 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()
| date | asset_id | asset_id_type | factor_group | factor | exposure |
|---|---|---|---|---|---|
| date | str | str | str | str | f32 |
| 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()
| date | n_assets | min_exposure | max_exposure | mean_exposure | std_exposure |
|---|---|---|---|---|---|
| date | i64 | f32 | f32 | f64 | f64 |
| 2025-03-31 | 500 | -3.3125 | 2.9140625 | -0.1275 | 0.897733 |
| 2025-04-01 | 500 | -3.648438 | 3.1796875 | -0.133088 | 0.922186 |
| 2025-04-02 | 500 | -3.322266 | 2.808594 | -0.093945 | 0.919222 |
| 2025-04-03 | 500 | -3.248047 | 2.9140625 | -0.175863 | 0.898337 |
| 2025-04-04 | 500 | -3.064453 | 2.857422 | -0.085856 | 0.896702 |
| … | … | … | … | … | … |
| 2026-03-25 | 496 | -3.105469 | 3.552734 | 0.061672 | 0.988378 |
| 2026-03-26 | 496 | -3.160156 | 3.707031 | 0.060841 | 0.981063 |
| 2026-03-27 | 496 | -3.427734 | 3.503906 | 0.058309 | 0.963608 |
| 2026-03-30 | 496 | -3.041016 | 3.505859 | 0.098044 | 0.938905 |
| 2026-03-31 | 496 | -2.851562 | 3.472656 | 0.065479 | 0.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_fillcarries the last known value across dates the upload has no row for, gated by the days the asset is in the modeling universe.gaussianizeconverts the values to standard-normal ranks;gaussianize_maintain_zeroskeeps zeros at zero while doing so.fill_missfills 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()
| date | bayesid | market.Market | my_signals.value_x_momentum | my_signals.random_signal |
|---|---|---|---|---|
| date | str | f32 | f32 | f32 |
| 2025-03-31 | "IC006CA2E0" | 1.0 | -0.506836 | 0.3046875 |
| 2025-03-31 | "IC0121F541" | 1.0 | -1.136719 | -1.040039 |
| 2025-03-31 | "IC012D373B" | 1.0 | 0.53418 | 0.750488 |
| 2025-03-31 | "IC01430182" | 1.0 | -0.056793 | 0.94043 |
| 2025-03-31 | "IC015A481B" | 1.0 | 0.398682 | -1.951172 |
risk_model.fret().select(
"date",
"market.Market",
"my_signals.value_x_momentum",
"my_signals.random_signal",
).head()
| date | market.Market | my_signals.value_x_momentum | my_signals.random_signal |
|---|---|---|---|
| date | f32 | f32 | f32 |
| 2025-03-31 | 0.0 | 0.0 | 0.0 |
| 2025-04-01 | 0.007404 | 0.00077 | -0.000646 |
| 2025-04-02 | 0.02212 | 0.001807 | -0.000461 |
| 2025-04-03 | -0.075951 | -0.002974 | 0.003802 |
| 2025-04-04 | -0.030753 | 0.005609 | -0.001347 |
risk_model.t_stats().select(
"date",
"market.Market",
"my_signals.value_x_momentum",
"my_signals.random_signal",
).head()
| date | market.Market | my_signals.value_x_momentum | my_signals.random_signal |
|---|---|---|---|
| date | f32 | f32 | f32 |
| 2025-03-31 | NaN | NaN | NaN |
| 2025-04-01 | 2.433605 | 0.841934 | -1.57795 |
| 2025-04-02 | 7.822511 | 2.10416 | -1.582667 |
| 2025-04-03 | -7.677681 | -1.007727 | 3.671594 |
| 2025-04-04 | -4.016456 | 2.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()
| date | asset_id | value_x_momentum | random_signal |
|---|---|---|---|
| date | str | f32 | f32 |
| 2025-03-31 | "IC006CA2E0" | -0.506625 | 0.304717 |
| 2025-03-31 | "IC0121F541" | -1.136421 | -1.039984 |
| 2025-03-31 | "IC012D373B" | 0.534355 | 0.750451 |
| 2025-03-31 | "IC01430182" | -0.056781 | 0.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()
| date | bayesid | my_signals.value_x_momentum | my_signals.random_signal | value_x_momentum | random_signal |
|---|---|---|---|---|---|
| date | str | f32 | f32 | f32 | f32 |
| 2025-03-31 | "IC006CA2E0" | -0.506836 | 0.3046875 | -0.506625 | 0.304717 |
| 2025-03-31 | "IC0121F541" | -1.136719 | -1.040039 | -1.136421 | -1.039984 |
| 2025-03-31 | "IC012D373B" | 0.53418 | 0.750488 | 0.534355 | 0.750451 |
| 2025-03-31 | "IC01430182" | -0.056793 | 0.94043 | -0.056781 | 0.940565 |
| 2025-03-31 | "IC015A481B" | 0.398682 | -1.951172 | 0.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()