Adding a New Model¶
This guide explains how to integrate a new forecasting model into the ecosystem. All model integration is done through the forecast_realtime package, which provides the base class, orchestration and caching infrastructure.
Where to Put the Code¶
Put your model in your own project; you do not need to edit or install files
inside forecast_realtime. To try the Python moving-average example below,
create this file:
Paste both Python blocks from Example: Moving Average in
Python into run_forecast.py: put the
MovingAverage class first and the real-time forecast code after it. Then run:
For a larger project, move the class to a separate module and import it into
run_forecast.py. No package registration is required. Models written in R,
MATLAB, or Julia need an additional external script; Where to Put an External
Script explains how to supply its path.
Interface Requirements¶
Any model that participates in the ecosystem must subclass ForecastModel and provide three things:
__init__(...) — Configuration¶
Store all hyperparameters and settings that _fit and _forecast will need. This is the only place to accept user-facing arguments (e.g. regularisation strength, window size, number of estimators). It should also initialise any placeholders for fitted state (e.g. self.model = None) so the object is fully described before any data are seen.
Always call super().__init__(label=label, formula=formula) to register the
model label and optional transformation mapping when your model exposes it:
def __init__(
self,
my_param=1.0,
label=None,
formula=None,
data_transformation=None,
):
super().__init__(
label=label,
formula=formula,
data_transformation=data_transformation,
)
self.my_param = my_param
label: string tag attached to all forecasts produced by this model instance. Defaults to the class name. Overridden byRealTimeModel.forecast(label=...).formula: R-style formula string (e.g."cpisa ~ gdpkp + unemp"or"cpisa ~ .") that selects which y and X columns are used. Applied after lag augmentation.None= use all columns.data_transformation: optional plain mapping from each input variable to the metric used by this model. It takes precedence over the call-leveldata_transformationmapping. Forecasts use the same metric as their target input;RealTimeModelreconstructs levels where possible.conditioning: optional model-owned conditioning policy. EachyorXentry names a source and a positiveperiodscount.Noneinherits the run-level fallback; a non-empty mapping replaces it;{}disables externally supplied conditioning. The policy affects realtime source selection only, not direct calls with explicityandXframes.
Lag features are not constructor parameters.
y_lagsandX_lagsare passed toForecastModel.fit()(orRealTimeModel.forecast()), which builds the lagged design matrix before calling_fit(). Do not handle lag construction in__init__or_fit().
_fit(y, X=None, **kwargs) — Estimation¶
Estimate the model using historical data (y), for example by fitting regression coefficients, training tree splits, or computing summary statistics from the observed time series. After this call, the model should be ready to produce forecasts.
_fit() receives the already-processed design matrix. If lags or dummies
were specified, ForecastModel.fit() has already appended those columns to
X. build_lagged_design retains NaNs; after _prepare_estimation_inputs(),
rows with missing estimation values are dropped only when
_handles_missing_values=False. Do not call build_lagged_design or do any
lag construction inside _fit().
Inputs:
| Argument | Type | Description |
|---|---|---|
y |
pd.DataFrame |
Target variable(s). Index: normally a DatetimeIndex; a supported direct identity path may preserve a PeriodIndex. Values: already transformed (e.g. growth rates). This is the prepared estimation target; rows with missing values may remain when the model handles them, and are otherwise dropped after _prepare_estimation_inputs(). |
X |
pd.DataFrame or None |
Design matrix, potentially augmented with lags and dummies. Before formula selection, column order is base X cols, then _y_lag1…_y_lagk, then col_lag1…col_lagk per X column, followed by dummies. None if there are no base, lag or dummy regressors. |
**kwargs |
Extra keyword arguments forwarded from RealTimeModel.forecast(..., **kwargs). y_lags and X_lags are not present here — they are consumed by ForecastModel.fit(). |
Example y (quarterly, single variable, data_transformation={"cpisa": "pop"}):
Example y (quarterly, multivariate):
Must return self.
_forecast(steps, X=None, y=None, quantiles=None, **kwargs) — Forecasting¶
Produce multi-step-ahead forecasts using the fitted model. This method is called after _fit() and should return predicted values for the next steps periods. Each row of the output corresponds to a forecast horizon; row 0 is the first forecast row, row 1 is the next row, and so on.
Inputs:
| Argument | Type | Description |
|---|---|---|
steps |
int |
Number of periods ahead to forecast (always ≥ 1). |
X |
pd.DataFrame or None |
Prepared design over history and any supplied forecast conditioning, indexed by a DatetimeIndex. Select forecast rows by date or forecast origin; they are not guaranteed to be the final steps rows. Column order matches the X passed to _fit. None when omitted; lag or dummy settings may still create a design. |
y |
pd.DataFrame or None |
Prepared target history plus conditioning paths over the horizon when conditioning is supplied, indexed by a DatetimeIndex. With an explicit transformation, y includes prepared history even without conditioning; with implicit identity, it may be None when no conditioning is supplied. Column order matches the y passed to _fit. Non-missing future values provide conditioning; NaN values are unconstrained. |
quantiles |
tuple[float, ...] or None |
None requests the point payload. A tuple requests native-metric quantiles for the sorted probabilities supplied to the public call. |
**kwargs |
Additional keyword arguments. |
Hook output:
Must return either a pd.DataFrame or an array-like object of shape (steps, n_y_variables):
- Index: a supplied pd.DataFrame must have its own pd.DatetimeIndex of length steps, one date per horizon. For an array-like result, the base class supplies standard dates from the effective forecast origin. By default, the first date is one period after that origin. Mixed-frequency models (e.g. MIDAS) can build a DataFrame with their own anchor dates.
- Rows correspond to forecast horizons 0, 1, …, steps−1; horizon 0 is the first forecast row.
- Columns must match the order and count of columns in the y DataFrame that was passed to _fit().
- Values must be in the metric declared by the model's data_transformation
(or the call-level fallback). RealTimeModel handles back-transformation to
levels automatically; model code must not back-transform its own output.
The hook keeps this array or wide-DataFrame contract. ForecastModel converts
the payload to the public long form, then constructs a ForecastResult, whose
constructor performs the final validation.
Example output for steps=4, 1 variable:
pd.DataFrame(
[[0.7], [0.6], [0.5], [0.4]],
index=pd.date_range("2024-03-31", periods=4, freq="QE", name="date"),
columns=["cpisa"],
) # shape: (4, 1)
Example output for steps=4, 2 variables:
pd.DataFrame(
[[0.7, 0.3], [0.6, 0.4], [0.5, 0.5], [0.4, 0.6]],
index=pd.date_range("2024-03-31", periods=4, freq="QE", name="date"),
columns=["cpisa", "gdpkp"],
) # shape: (4, 2)
Public point results¶
Public forecast() calls return a DataFrame-compatible
ForecastResult with a RangeIndex and the long columns date, variable,
and value. Rows are ordered by date and fitted target order. The result keeps
forecast_origin and decomposition as metadata, while .forecast returns
the same long payload as an ordinary DataFrame.
ForecastResult validates and orders the payload at construction. Its
signature is:
ForecastResult(
forecast,
*,
expected_columns,
steps,
forecast_origin,
decomposition=None,
quantiles=False,
forecast_dates_include_origin=False,
)
Point and quantile results may use a custom calendar supplied by the hook,
subject to the step count and forecast-origin rules. Quantile probabilities
within float noise of a requested probability are replaced by it. The original
result retains .forecast, .forecast_origin, and .decomposition; slices and
copies return ordinary DataFrames without result metadata. Validation occurs at
construction, not after later mutation.
Use an explicit pivot when a downstream calculation needs a wide point matrix:
point_result = model.forecast(steps=4)
point_matrix = point_result.pivot(
index="date",
columns="variable",
values="value",
)
Model extensions implement _fit(), _forecast(), and the optional
_forecast_decomp() hooks, together with the existing preparation hooks.
Replacing public forecast() orchestration is not a supported extension point.
Quantile forecasts¶
Opt in explicitly by setting _supports_quantiles = True on the model class.
The default is False, so the base class rejects quantile requests for models
that have not implemented this contract. The public quantiles=False becomes
None here; True becomes (0.16, 0.5, 0.84), and a supplied sequence arrives
as sorted, distinct, finite probabilities between 0 and 1.
When quantiles is not None, return one pd.DataFrame with exactly these
columns:
Return one row for every requested date, fitted target variable, and
probability. Values must be finite, and the quantiles for each date and
variable must not cross. Result construction checks the columns, complete
coverage, unique keys, finite values, and ordering, then wraps the table in a
single ForecastResult. Do not return a point forecast alongside the quantile
table.
The quantile table uses the model's native forecast metric. A model may compute quantiles analytically or summarise joint paths privately, but predictive draws are not part of the public model contract and must not be returned or stored.
The public quantile ForecastResult keeps a RangeIndex and the same column
order shown above. It contains only the requested quantile rows, not a point
forecast alongside them.
The base class validates the returned shape and the number of target columns. A returned DataFrame must supply a DatetimeIndex; array-like results receive the standard forecast dates when the base class wraps them.
Target conditioning capability¶
Target conditioning is an explicit model capability. Set
_supports_target_conditioning = True only when the model's forecasting logic
actually enforces non-missing future values supplied through y; accepting a
y parameter is not enough. The base value is False, so unsupported models
reject explicit future target constraints in both realtime and direct
forecast/predict calls. Historical values, empty frames and all-NaN future
paths remain valid inputs.
This declaration is required for custom and external models as well as Python
models. External wrappers must forward the public conditioning constructor
argument to ForecastModel, but must not pass it as an estimator or script
parameter. Source policies do not create constrained forecasting for a
model that has not opted in.
ForecastContext data¶
Preparation logic that inspects ForecastContext must keep explicit constraints
and published observations separate:
context.y_conditioningcontains only explicit caller-supplied target constraints, withy_conditioning_input_metricsfor their input units.context.y_publishedcontains published target observations retained for the forecast, withy_published_input_metricsfor their input units.
Use y_published when preparation logic needs ordinary published observations.
Do not read those observations from y_conditioning or merge the two frames at
the context boundary; the preparation pipeline combines them after validation.
This preserves explicit constraints even when a later transformation produces
NaNs.
The package's generated API reference documents the Python signatures. The external ecosystem's API/skill manifest is maintained outside this repository; update it as part of the release handoff rather than adding a local copy here.
Data and Extension Boundary¶
Model authors use the public fit(), forecast(), and
ForecastContext interfaces. The existing DataFrame contracts for
_prepare_fit_inputs(), _prepare_forecast_inputs(),
_prepare_estimation_inputs(), _fit(), _forecast(), and
_forecast_decomp() remain in force. Use those hooks; do not import or depend
on the private ModelData implementation.
Internally, FittedModelConfiguration and its FittedDataTransformation
record the policy selected at fit time, while the canonical long-labelled
ModelData keeps raw observations and provenance separate from prepared
DataFrames. ModelInputRequirements is shared by ordinary models and trees:
it reports the raw y and X roles a consumer needs even when no mapping is
configured.
Trees label composition inputs with their actual synthetic columns and native
metrics. Callable nodes retain their dictionary-of-DataFrames interface and
the established levels output-metric convention; their input values are not
necessarily all levels because native child outputs may use other metrics.
RealTimeModel chooses vintages and horizons and schedules forecast tasks;
ModelData performs as-of selection for each task. Worker tasks carry
ModelData rather than parallel frames and metadata. Counterfactual models
built from replacement data are copied so hook caches cannot leak between
evaluations.
_forecast_decomp(steps, X=None, y=None, **kwargs) — Forecast Decomposition (Optional)¶
Return additive components of the current forecast in the model's native target metric. RealTimeModel evaluates counterfactuals across vintages to derive forecast revisions as:
- News: revision from new data released
- Reestimation: revision from model refit (parameter changes, not new data)
- Interaction: cross-term combining both effects
This method is optional. If not implemented, return None and the model will not produce decompositions.
Inputs:
| Argument | Type | Description |
|---|---|---|
steps |
int |
Number of periods ahead to forecast (same as _forecast). |
X |
pd.DataFrame or None |
Full augmented design matrix (same as passed to _forecast). |
y |
pd.DataFrame or None |
Prepared history and conditioning, when supplied (same as passed to _forecast). |
**kwargs |
Additional keyword arguments. |
Output (minimal contract):
Return pd.DataFrame or None:
- If decomposition not supported: return
None - If decomposition computed, one row per component per horizon:
forecast_horizon(int): 0-based horizon indexcomponent(str): name of the component (e.g.'intercept','gdpkp','cpisa_lag1')contribution(float): additive effect in the native target metric; values must sum to the current forecast for each horizon
weight(float or NaN): model coefficient (NaN if not applicable, e.g. black-box models)
RealTimeModel augments these rows with metadata (variable, date, vintage_date, frequency, source, forecast_metric, decomposition, revision_source, base_vintage_date) before storing in rt_model.decompositions. It derives news, reestimation, and interaction by comparing counterfactual evaluations across vintages. The model does not need to return these columns or implement vintage revision scheduling.
Example output (OLS with 2 regressors + intercept, steps=4):
pd.DataFrame(
{
"forecast_horizon": [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3],
"component": ["intercept", "payrolls", "ip"] * 4,
"contribution": [-0.9, 0.5, 0.06] * 4,
"weight": [np.nan, 0.5, 0.1] * 4,
}
) # 12 rows × 4 cols; each horizon has 3 components
Important notes:
forecast_horizonis 0-based (0 = first forecast row)contributionvalues must sum to the total forecast for each horizonweightcan beNaNfor non-parametric or black-box models- Simple models (e.g. moving average) can return
Noneand skip decomposition - Do not include
news,revision_source,vintage_date, or other metadata —RealTimeModeladds those
You can also implement the model in another language; see Language Interoperability below.
Example: Moving Average in Python¶
A moving average model forecasts every horizon as the mean of the last window_size observations. This is the simplest useful example to illustrate the interface.
Step 1: Subclass ForecastModel¶
import numpy as np
import pandas as pd
from forecast_realtime import ForecastModel
class MovingAverage(ForecastModel):
"""Moving-average forecast: predict the mean of the last `window_size` observations.
Parameters
----------
window_size : int
Number of trailing observations to average.
"""
def __init__(self, window_size: int = 4, label=None):
super().__init__(label=label)
self.window_size = window_size
self.window_mean = None # Has shape (n_variables,) after fitting.
def _fit(self, y: pd.DataFrame, X: pd.DataFrame = None, **kwargs):
"""Compute the mean of the last `window_size` rows of y.
Parameters
----------
y : pd.DataFrame
DatetimeIndex, one column per variable.
Values are already transformed (e.g. growth rates).
X : ignored
"""
tail = y.iloc[-self.window_size :] # last window_size rows
self.window_mean = tail.mean().values # Has shape (n_variables,).
return self
def _forecast(
self,
steps: int,
X: np.ndarray = None,
y: np.ndarray = None,
**kwargs,
) -> pd.DataFrame:
"""Return the moving-average value for every horizon.
Parameters
----------
steps : int
Number of horizons to forecast.
X, y : ignored
Returns
-------
pd.DataFrame, shape (steps, n_variables)
Same value repeated for each step, indexed by a DatetimeIndex
of length ``steps`` (built by ``_wrap_forecast`` from
``self.y.index``).
"""
# Tile the mean across all forecast horizons, then wrap with the
# standard inferred-date DataFrame.
arr = np.tile(self.window_mean, (steps, 1))
return self._wrap_forecast(arr, steps)
def _forecast_decomp(
self,
steps: int,
X: np.ndarray = None,
y: np.ndarray = None,
**kwargs,
) -> pd.DataFrame:
"""Return decomposition: for moving average, all contribution from 'window_mean' component."""
components = []
for h in range(steps):
for var_idx, var_col in enumerate(self.y.columns):
components.append(
{
"forecast_horizon": h,
"component": "window_mean",
"contribution": self.window_mean[var_idx],
"weight": np.nan,
}
)
return pd.DataFrame(components)
Step 2: Run Real-time Forecasts¶
import forecast_evaluation as fe
import forecast_realtime as rt
sample_data = rt.generate_synthetic_data(
N=2,
first_period="2015-01-31",
endpoint="2024-12-31",
)
forecast_data = fe.NowcastData(outturns_data=sample_data)
ma_model = MovingAverage(window_size=4)
rt_model = rt.RealTimeModel(data=forecast_data, models=ma_model)
# Run forecasts (optionally with decomposition)
rt_model.forecast(
y_variables=["quarterly_1"],
data_transformation={"quarterly_1": "pop"},
steps=2,
label="MA(4)",
first_vintage="2024-01-31",
last_vintage="2024-06-30",
decomp=False, # Set to True to enable decomposition
)
# Optional interactive dashboard:
# rt_model.data.run_dashboard()
# If decomp=True, access decompositions:
# print(rt_model.decompositions) # DataFrame with component breakdown
Example: OLS with Decomposition¶
Ordinary Least Squares (OLS) forecasts with interpretable component decomposition. This example shows how _forecast_decomp() returns additive components of the current forecast in the model's native target metric; RealTimeModel compares counterfactuals across vintages to derive data news, parameter reestimation, and interaction effects.
Step 1: Subclass ForecastModel with decomposition support¶
import numpy as np
import pandas as pd
from forecast_realtime import ForecastModel
from sklearn.linear_model import LinearRegression
class SimpleOLS(ForecastModel):
"""OLS regression with forecast decomposition support.
Parameters
----------
fit_intercept : bool
Whether to include an intercept term.
"""
def __init__(self, fit_intercept: bool = True, label=None, formula=None):
super().__init__(label=label, formula=formula)
self.fit_intercept = fit_intercept
self.model = None
self.intercept_ = None
self.coef_ = None
def _fit(self, y: pd.DataFrame, X: pd.DataFrame = None, **kwargs):
"""Fit OLS to y and X."""
if X is None or X.shape[1] == 0:
raise ValueError("SimpleOLS requires X_variables")
self.model = LinearRegression(fit_intercept=self.fit_intercept)
self.model.fit(X, y.values)
self.intercept_ = self.model.intercept_
self.coef_ = self.model.coef_
return self
def _forecast(self, steps: int, X=None, y=None, **kwargs) -> np.ndarray:
"""Forecast using OLS: y = intercept + X @ coef."""
if X is None:
raise ValueError("SimpleOLS requires X (future regressors)")
future_X = X.loc[X.index > self.last_y_fit_date].iloc[:steps]
forecasts = future_X.to_numpy(dtype=float) @ self.coef_.T + self.intercept_
return forecasts
def _forecast_decomp(self, steps: int, X=None, y=None, **kwargs) -> pd.DataFrame:
"""Decompose forecast into intercept + regressor components.
Returns one row per component per horizon with columns:
forecast_horizon, component, contribution, weight.
"""
if X is None:
return None
components = []
future_X = X.loc[X.index > self.last_y_fit_date].iloc[:steps]
X_cols = list(future_X.columns)
for h in range(steps):
# Intercept contribution
components.append(
{
"forecast_horizon": h,
"component": "intercept",
"contribution": float(self.intercept_),
"weight": np.nan,
}
)
# Regressor contributions
for col_idx, col_name in enumerate(X_cols):
x_value = future_X.iloc[h, col_idx]
contribution = float(self.coef_[col_idx]) * x_value
components.append(
{
"forecast_horizon": h,
"component": col_name,
"contribution": contribution,
"weight": float(self.coef_[col_idx]),
}
)
return pd.DataFrame(components)
Step 2: Run OLS with decomposition enabled¶
import forecast_evaluation as fe
import forecast_realtime as rt
sample_data = rt.generate_synthetic_data(
N=2,
first_period="2015-01-31",
endpoint="2024-12-31",
)
forecast_data = fe.NowcastData(outturns_data=sample_data)
ols_model = SimpleOLS(fit_intercept=True)
rt_model = rt.RealTimeModel(data=forecast_data, models=ols_model)
rt_model.forecast(
y_variables=["quarterly_1"],
X_variables=["quarterly_2"],
data_transformation={"quarterly_1": "pop", "quarterly_2": "pop"},
steps=2,
label="OLS",
first_vintage="2024-01-31",
last_vintage="2024-06-30",
X_imputation="last",
decomp=True, # Enable decomposition
)
# Access decomposition results
print(rt_model.decompositions)
# Output includes forecast_horizon, variable, date, vintage_date, frequency,
# source, forecast_metric, decomposition, revision_source, base_vintage_date,
# component, contribution and weight columns.
# Shows how each regressor + intercept contributed to each horizon's forecast
Language Interoperability¶
Models written in R, Julia, MATLAB, or another language integrate through the
provided classes in forecast_realtime:
| Language | Class | CLI executable |
|---|---|---|
| R | RModel |
Rscript |
| MATLAB | MATLABModel |
matlab |
| Julia | JuliaModel |
julia |
All three inherit from ExternalModel. It manages the temporary directory,
Parquet I/O, parameter deserialisation, command dispatch, subprocess
execution, and forecast output. You provide the model logic.
External scripts are trusted executable code. They are launched as subprocesses
without a shell, and generated path literals are quoted for the target
language, but this does not sandbox the script or its parameters. Fable
spec and xreg values have the same trusted R-expression contract.
Write two functions: fit(y, X, params) returns a model object, and
forecast(model, steps, X, y, params) returns a data frame or matrix. The
argument order mirrors ForecastModel._fit and _forecast, with model
standing in for self and params for **kwargs.
The external forecast function returns the model hook payload, so point output
may remain an array or wide table here. The Python wrapper converts it to the
public long ForecastResult contract, whose constructor validates the result.
The inputs follow the Python forecasting contract above. Supplied X can
contain prepared history and conditioning, not just one row per forecast step.
Select horizon rows by date and the fitted state or forecast_origin parameter.
Absent inputs use NULL, [] or nothing, as appropriate; lags and dummies can
create X even without base regressors. An explicit transformation supplies y
history even without conditioning; implicit identity can leave y absent.
When present, y and X include a date column containing their pandas index.
Use it to align time-series data, and exclude it from numerical regressors
unless the model explicitly uses dates.
What the Package Handles for You¶
fit()writesy.parquet(and optionallyX.parquet) to a temporary directory, loadsyandXinto data frames (XisNULL/[]/nothingwhen absent), deserialises your keyword arguments intoparams, calls yourfit(y, X, params)function, and saves the returned model object to disk (model.rds/model.mat/model.jls). Their pandas indexes are stored as adatecolumn.forecast()loadsyand the preparedX, deserialises the saved model, calls yourforecast(model, steps, X, y, params)function, takes the returned data frame / matrix and writes it toforecasts.parquet, then returns the result as apd.DataFrame. Select horizon rows from the supplieddatecolumn using the fitted state orforecast_originparameters; do not assume that the finalstepsrows are the forecast. For array-like output, the base class wraps it with the standard inferred-dateDatetimeIndex.- The temporary directory is automatically deleted when the model object is garbage-collected.
Your functions never touch cache_dir, saveRDS, write_parquet, or any other file I/O — the runner scripts handle all of that.
Where to Put an External Script¶
Save the external script wherever you keep your model code. When you create an
RModel, MATLABModel, or JuliaModel, pass the path to that script. The
script does not have to share a directory with your Python file.
Keeping both files together is a simple option. For the R example below, the project would look like this:
Use ma_model.m for MATLAB or ma_model.jl for Julia. Put the Python wrapper
code in run_forecast.py, and build the script path from that file's location:
from pathlib import Path
script = Path(__file__).resolve().with_name("ma_model.R")
model = RModel(str(script), window_size=4)
This path works whether you run python run_forecast.py from the project
directory or invoke the file from elsewhere. A bare path such as
"ma_model.R" depends on the process's current working directory and may fail
later when R, MATLAB, or Julia starts.
If you keep external scripts in a subdirectory, include it in the path:
script = Path(__file__).resolve().parent / "models" / "ma_model.R"
model = RModel(str(script), window_size=4)
In a notebook, __file__ is unavailable. Define the project directory
explicitly and build the path from it:
project_dir = Path("/absolute/path/to/my_forecast_project")
model = RModel(str(project_dir / "ma_model.R"), window_size=4)
The package neither searches for the script nor copies it into your project.
The examples below use the two-file layout above. For a complete MATLAB wrapper,
see tests/models/matlab_scripts/demo_forecast_lm_matlab.py.
Function Signatures Your Script Must Define¶
| Language | fit |
forecast |
|---|---|---|
| R | fit(y, X, params) → returns a model object (e.g. a list) |
forecast(model, steps, X, y, params) → returns a data.frame |
| MATLAB | result = my_model('fit', y, X, params) → returns a struct |
result = my_model('forecast', model, steps, X, y, params) → returns a table |
| Julia | fit(y, X, params) → returns any serialisable object |
forecast(model, steps, X, y, params) → returns a DataFrame |
Example: Moving Average in R¶
RModel takes the path to your .R script plus any keyword arguments you want forwarded as parameters:
from pathlib import Path
import forecast_evaluation as fe
import forecast_realtime as rt
from forecast_realtime import RModel
sample_data = rt.generate_synthetic_data(
N=2,
first_period="2015-01-31",
endpoint="2024-12-31",
)
forecast_data = fe.NowcastData(outturns_data=sample_data)
# Resolve the script relative to this file so it works from any directory
# "window_size=4" becomes params$window_size inside the R script
model = RModel(str(Path(__file__).parent / "ma_model.R"), window_size=4)
rt_model = rt.RealTimeModel(data=forecast_data, models=model)
rt_model.forecast(
y_variables=["quarterly_1"],
data_transformation={"quarterly_1": "pop"},
steps=2,
label="MA(4) R",
first_vintage="2024-01-31",
last_vintage="2024-06-30",
)
The R script ma_model.R looks like:
# ma_model.R — only defines fit() and forecast()
fit <- function(y, X, params) {
window_size <- as.integer(params$window_size)
y <- y[, setdiff(colnames(y), "date"), drop = FALSE]
n <- nrow(y)
tail_df <- y[max(1, n - window_size + 1):n, , drop = FALSE]
window_mean <- sapply(tail_df, mean)
# Return a model object — the runner saves it to model.rds
list(window_mean = window_mean, col_names = colnames(y))
}
forecast <- function(model, steps, X, y, params) {
window_mean <- model$window_mean
n_vars <- length(window_mean)
fcst <- matrix(rep(window_mean, each = steps), nrow = steps, ncol = n_vars)
out <- as.data.frame(fcst)
colnames(out) <- model$col_names
# Return a data.frame — the runner writes it to forecasts.parquet
out
}
Example: Moving Average in MATLAB¶
MATLABModel takes the path to your .m file. The file's stem is called as a MATLAB function:
from pathlib import Path
import forecast_evaluation as fe
import forecast_realtime as rt
from forecast_realtime import MATLABModel
sample_data = rt.generate_synthetic_data(
N=2,
first_period="2015-01-31",
endpoint="2024-12-31",
)
forecast_data = fe.NowcastData(outturns_data=sample_data)
# Resolve the script relative to this file so it works from any directory
# "window_size=4" becomes params.window_size inside the MATLAB function
model = MATLABModel(str(Path(__file__).parent / "ma_model.m"), window_size=4)
rt_model = rt.RealTimeModel(data=forecast_data, models=model)
rt_model.forecast(
y_variables=["quarterly_1"],
data_transformation={"quarterly_1": "pop"},
steps=2,
label="MA(4) MATLAB",
first_vintage="2024-01-31",
last_vintage="2024-06-30",
)
The MATLAB function ma_model.m looks like:
function result = ma_model(action, varargin)
% fit: result = ma_model('fit', y, X, params)
% forecast: result = ma_model('forecast', model, steps, X, y, params)
if strcmp(action, 'fit')
y = varargin{1};
X = varargin{2}; % regressors (empty [] when none)
params = varargin{3};
window_size = params.window_size;
y_arr = table2array(y);
n = size(y_arr, 1);
tail_y = y_arr(max(1, n - window_size + 1):n, :);
window_mean = mean(tail_y, 1);
% Return a model struct — the runner saves it to model.mat
result.window_mean = window_mean;
result.col_names = y.Properties.VariableNames;
elseif strcmp(action, 'forecast')
model = varargin{1};
steps = varargin{2};
X = varargin{3}; % prepared design, possibly including history and conditioning
y = varargin{4};
fcst = repmat(model.window_mean, steps, 1);
% Return a table — the runner writes it to forecasts.parquet
result = array2table(fcst, 'VariableNames', model.col_names);
end
end
Example: Moving Average in Julia¶
JuliaModel takes the path to your .jl script:
from pathlib import Path
import forecast_evaluation as fe
import forecast_realtime as rt
from forecast_realtime import JuliaModel
sample_data = rt.generate_synthetic_data(
N=2,
first_period="2015-01-31",
endpoint="2024-12-31",
)
forecast_data = fe.NowcastData(outturns_data=sample_data)
# Resolve the script relative to this file so it works from any directory
# "window_size=4" becomes params["window_size"] inside the Julia script
model = JuliaModel(str(Path(__file__).parent / "ma_model.jl"), window_size=4)
rt_model = rt.RealTimeModel(data=forecast_data, models=model)
rt_model.forecast(
y_variables=["quarterly_1"],
data_transformation={"quarterly_1": "pop"},
steps=2,
label="MA(4) Julia",
first_vintage="2024-01-31",
last_vintage="2024-06-30",
)
The Julia script ma_model.jl looks like:
# ma_model.jl — only defines fit() and forecast()
using Statistics
function fit(y, X, params)
window_size = Int(params[:window_size])
col_names = names(y)
n = nrow(y)
tail_start = max(1, n - window_size + 1)
window_mean = [mean(Float64.(y[tail_start:n, c])) for c in col_names]
# Return a model object — the runner serialises it to model.jls
Dict("window_mean" => window_mean,
"col_names" => col_names)
end
function forecast(model, steps, X, y, params)
window_mean = model["window_mean"]
fcst = repeat(transpose(window_mean), steps, 1)
# Return a DataFrame — the runner writes it to forecasts.parquet
DataFrame(fcst, model["col_names"])
end
Testing¶
Every model wrapper must include a test (in tests/models/) that verifies the wrapper produces exactly the same results as the native package when called directly. This ensures the wrapper is a transparent pass-through with no unintended side effects. Examples can be found in tests/models/test_midas.py and tests/models/test_bvar.py.
Debugging External Models¶
All external model classes support an interactive debug mode that launches the language's REPL with y, X and params already loaded, and your fit() / forecast() called automatically.
External model scripts are executed as code by R, MATLAB or Julia. Only use
scripts from a trusted source; path and command quoting does not sandbox the
script itself. For fable wrappers, spec and xreg are also trusted R
expressions and are evaluated by the R process.
Pass debug="fit" or debug="forecast" when creating the model:
from pathlib import Path
script = str(Path(__file__).parent / "ma_model.R")
model = RModel(script, debug="fit", window_size=4)
model.fit(y) # opens an interactive R REPL, calls fit()
model = RModel(script, debug="forecast", window_size=4)
model.fit(y) # runs fit normally
model.forecast(4) # opens an interactive R REPL, calls forecast()
Add breakpoints in your script before running:
| Language | Breakpoint command | Notes |
|---|---|---|
| R | browser() |
Pause and inspect; n to step, c to continue, Q to quit |
| MATLAB | Set breakpoints in the editor | debug="fit" opens the MATLAB desktop |
| Julia | @bp or @infiltrate |
Requires Debugger.jl or Infiltrator.jl |