Quant Trading: Stacked Ensemble Machine Learning & LSTMs for Stock Price Forecasting (Python Guide)
Implementing a multi-model hybrid stacking engine combining Random Forest, XGBoost, SVR, and deep LSTMs.
This review surveys conventional machine learning, time-series, deep-learning, and ensemble methods for stock-price forecasting and stock-trend classification. It also reports an implementation comparing SVR, MLPR, KNN, random forest, XG-Boost, LSTM, and a stacked Random Forest + XG-Boost + LSTM ensemble on Yahoo Finance data for TAINIWALCHM and AGROPHOS. The reported ensemble achieved the best RMSE and R2 values in the paper's comparison, although the exact preprocessing, feature construction, stacking procedure, and experimental code are not fully specified.
Use the URL at the end of this article to download the source code
Implementation Assumptions
The Section 4/Table 1 80/20 chronological split is the primary reproduction choice; the Section 3 75/25 recommendation is retained as a documented conflict.
Yahoo Finance access is optional at runtime; local CSV fixtures and deterministic synthetic data support offline demonstrations.
Ticker symbols, selected features, target column, scaler, look-back window, validation protocol, seeds, hyperparameter selections, and ensemble wiring remain explicit configuration choices because the paper does not specify them.
The LSTM is implemented with TensorFlow/Keras rather than a hand-written recurrent cell because equations eq8, eq9, and eq_10 are incomplete.
XG-Boost is accessed through an optional local Python dependency, with a clear compatibility boundary and no network-dependent behavior.
Canonical equation LaTeX is stored only from the supplied records; empty or damaged equation records are not reconstructed.
Table 2 values are stored as reported references and are never represented as independently verified results.
All data transformations that learn parameters are fitted on training data only.
The implementation is stock-specific by default, with separate preprocessing and model fitting for each security.
No live API call, model training, or code execution is claimed by this plan.
Scope, evidence status, and reproduction target
What does it mean to reproduce this paper when the paper does not include the original source code? The practical answer is to reproduce the documented experimental structure while making every missing choice visible. This project therefore aims to be auditable and configurable, not to claim an exact numerical reconstruction of the paper's experiment.
The paper is primarily a systematic review of stock-price forecasting and trend-classification methods. Its main implementation comparison, in Section 4, contains six individual regressors or forecasting models—SVR, MLPR, KNN, random forest, XG-Boost, and LSTM—plus a proposed Random Forest + XG-Boost + LSTM ensemble. A regression model predicts a numeric value, here a stock-price target. A baseline is an individual model used as a comparison point for the ensemble.
The two reported securities are TAINIWALCHM, described as Tainwala Chemicals and Plastics, and AGROPHOS, described as Agro Phos. The paper reports historical data obtained through Yahoo Finance and gives date ranges for these names, but it does not provide the exact Yahoo Finance ticker identifiers needed to make an unambiguous download request.
What is specified, and what is not
The strongest implementation evidence comes from Table 1. It specifies an 80% training and 20% testing split for the proposed ensemble, MSE loss and Adam for the neural component, a maximum of 50 epochs, batch size 32, two LSTM layers, dropout of 0.2, and a dense layer with 25 units. It also lists candidate values for random-forest and XG-Boost hyperparameters.
This conflicts with the paper's more general pipeline description, which mentions a 75/25 split. The framework uses the Table 1 80/20 split as its primary reproduction choice because it is the more specific statement about the proposed ensemble. The conflict remains recorded rather than silently discarded.
Several choices needed for an executable experiment are absent: the exact ticker symbols, selected features, target column, scaling method, LSTM look-back window, forecast horizon, validation procedure, random seeds, selected hyperparameters, and tuning algorithm. The paper also calls the combination “stacked” without defining its wiring. Stacking usually means fitting a second-stage model on component predictions, whereas blending or weighted averaging combines predictions directly, often with fixed weights. These are different operations, so the implementation exposes the distinction instead of presenting one as recovered fact.
The generated configuration objects make these decisions explicit. The following excerpt is copied from src/stock_forecasting/config.py; notice that feature columns, target, split fraction, sequence settings, and scaler are all fields rather than hidden constants.
@dataclass(frozen=True, slots=True)
class PreprocessingConfig:
"""Make feature, target, split, sequence, and scaling decisions explicit.
The paper's Table 1 specifies an 80/20 split for the proposed ensemble,
while its generic pipeline mentions 75/25. The default-free configuration
requires the caller to choose a fraction; a value of ``0.8`` is the
primary reproduction choice described by the paper. The target column,
look-back, horizon, feature set, and scaler are not specified by the paper.
"""
feature_columns: tuple[str, ...]
target_column: str
train_fraction: float
lookback: int
horizon: int
scaler_name: Optional[str]Here, feature_columns names the input columns, while target_column identifies the value to forecast. The paper mentions possible OHLCV and secondary data but does not establish the final feature set or target. lookback is the number of historical rows supplied to an LSTM sequence, and horizon describes how far ahead its target is aligned. Both are implementation decisions. scaler_name records whether a transformation such as standard or min-max scaling is used; the paper does not specify which one.
The manifest as an audit boundary
The experiment manifest separates paper facts from implementation decisions and unresolved ambiguities. ExperimentManifest records the stock identity, source configuration, feature and target choices, split policy, sequence settings, tuning description, model configurations, and ensemble combiner. validate_manifest checks that these fields are sufficiently explicit and serializable.
The generated class exposes this purpose directly through its fields:
@dataclass(frozen=True, slots=True)
class ExperimentManifest:
"""Audit record for one stock-specific reproduction configuration.
The manifest deliberately stores unresolved paper details as explicit fields or
notes rather than hiding them in defaults. ``model_configurations`` records
selected settings when supplied; an empty mapping means that no selected model
settings were provided by the caller or recovered from the paper.
"""
paper_id: str
stock: str
data_source: Mapping[str, Any]
feature_columns: tuple[str, ...]
target_column: str
train_fraction: float
split_policy: str
scaler_name: str | None
lookback: int
horizon: int
seed: int | None
tuning_method: str
validation_fraction: float | None
model_configurations: Mapping[str, Any]
combiner: str
ensemble_weights: tuple[float, ...] | None
meta_features: str
package_metadata: Mapping[str, Any]A small worked configuration would therefore identify the stock label separately from the source ticker, choose features such as Open, High, Low, and Volume, choose Close as the target, record an 80/20 chronological split, select a look-back and scaler, and state whether tuning is unspecified or explicitly configured. It would also record a seed and a combiner such as weighted_average or linear_stacking. Those values would document the reproduction run; they would not become claims about what the paper originally did.
For example, the public configuration contract requires an explicit data request rather than silently selecting a ticker:
@dataclass(frozen=True, slots=True)
class DataSourceConfig:
"""Describe one explicit historical-data request.
``ticker`` is deliberately required from the caller because the paper does
not provide the exact Yahoo Finance symbols for TAINIWALCHM or AGROPHOS.
Date endpoint inclusivity and interval semantics remain properties of the
selected Yahoo Finance client.
"""
ticker: str
start: str
end: str
interval: strThe source ticker, date strings, and interval are provenance, not merely convenience arguments. They make it possible to distinguish two runs that use different Yahoo Finance symbols, endpoint behavior, or sampling frequencies.
What the reported results mean
The paper's Table 2 reports RMSE and R2 for each stock and model. RMSE, or root mean square error, summarizes prediction error in the target's units; smaller values indicate smaller typical squared errors. R2, or the coefficient of determination, compares the predictions with a constant-mean reference. It can be negative for a poor regression model and is not required to remain between zero and one.
The paper reports the RF + XG-Boost + LSTM ensemble as having the strongest comparison values: RMSE 2.0247 and R2 0.9921 for TAINIWALCHM, and RMSE 1.2658 and R2 0.9897 for AGROPHOS. These are reported references only. The generated code has not been executed, no Yahoo Finance request has been made, and no model training, test run, syntax check, or independent numerical verification has occurred. Consequently, this tutorial must not describe those values as reproduced results.
One additional fidelity issue is retained rather than corrected: the supplied paper discussion refers to the AGROPHOS date range in one sentence using the TAINIWALCHM name. The framework records the source and stock explicitly so that a reader can resolve this issue deliberately when supplying data.
In the rest of the tutorial, fit_models_and_generate_predictions will be treated as the training-and-prediction procedure, stacked_rf_xgboost_lstm as the configurable ensemble method, and evaluate_regression_predictions as the held-out evaluation step. A held-out test set is data reserved until model fitting and model selection are complete. Keeping it separate is a derived implementation safeguard, not evidence that the original paper used exactly the same procedure.
The central reproduction principle is therefore simple: preserve the paper's stated settings, expose every missing setting, and label reported metrics as references until an independently specified and executed experiment supports a comparison.
Project layout and public contracts
How can one keep a stock-forecasting reproduction understandable when it contains data cleaning, several model families, an LSTM, an ensemble, and multiple reporting steps? The practical answer is to give each stage one responsibility and connect stages with explicit contracts. In this project, data modules obtain and validate rows, preprocessing modules construct examples, model adapters fit estimators, ensemble modules align and combine predictions, and metric modules evaluate the final outputs.
This separation is an implementation design derived from the paper's pipeline description. The paper says that data acquisition, preprocessing, model training, evaluation, and tuning are distinct stages, but it does not prescribe a Python package layout. The generated package makes that progression visible through modules under data, models, ensemble, training, metrics, and experiments.
A map of the package
The lightweight package entry point is src/stock_forecasting/__init__.py. It exports configuration types but does not import TensorFlow, XG-Boost, or Yahoo Finance integrations eagerly. This matters because configuration and local data work should remain available even when optional modeling or network dependencies are not installed.
The dependency boundary is declared in pyproject.toml. The core installation contains NumPy, pandas, and scikit-learn, while deep learning, Yahoo Finance access, XG-Boost, plotting, and development tools are optional extras:
[project.optional-dependencies]
# TensorFlow supplies the Keras LSTM implementation described in Table 1.
deep-learning = [
"tensorflow>=2.13",
]
# Yahoo Finance access is opt-in; local CSV and synthetic workflows remain offline.
yahoo = [
"yfinance>=0.2.30",
]
# XG-Boost is optional because the paper does not specify its package or version.
xgboost = [
"xgboost>=1.7",
]
plotting = [
"matplotlib>=3.7",
]This is not a claim about the paper's original package versions. It is a reproducibility decision for the generated framework. In particular, a local CSV or synthetic-data demonstration should not require a network client, and importing configuration should not fail merely because TensorFlow is unavailable.
The main hand-off records are defined in src/stock_forecasting/types.py. They carry arrays together with timestamps and validate their basic shape and finiteness requirements. This is important for the methods preprocess_time_series, fit_models_and_generate_predictions, stacked_rf_xgboost_lstm, and evaluate_regression_predictions: each method depends on receiving data with the intended meaning, not merely an object that happens to be an array.
Two representations of the same forecasting problem
Conventional regressors use a tabular feature matrix, which we will call X_tab. Its shape is rank 2:
(n_samples, n_features)means one independent feature row per training or evaluation example.yis the aligned numeric target, usually represented as(n_samples,)or(n_samples, 1).
The LSTM instead uses a sequence tensor, which we will call X_seq. Its shape is rank 3:
(n_sequences, lookback, n_features)means each example contains an ordered window oflookbackobservations.The corresponding
ycontains one future target for each sequence.
The distinction is semantic. A tabular row says, in effect, “use these features for this example.” A sequence window says, “use this ordered history to predict the associated future target.” The generated TabularSplit and SequenceSplit records enforce these different contracts rather than treating every input as a generic two-dimensional matrix.
A focused portion of TabularSplit shows the tabular contract and its alignment checks:
@dataclass
class TabularSplit:
"""Chronological train/test arrays for conventional regressors.
``X_train`` and ``X_test`` have shape ``(n_samples, n_features)``. Targets
may have shape ``(n_samples,)`` or ``(n_samples, 1)`` and must align with
their corresponding timestamp indices.
"""
X_train: np.ndarray
X_test: np.ndarray
y_train: np.ndarray
y_test: np.ndarray
train_index: pd.Index
test_index: pd.Index
def __post_init__(self) -> None:
x_train = _as_numeric_array(self.X_train, "X_train")
x_test = _as_numeric_array(self.X_test, "X_test")
if x_train.ndim != 2 or x_test.ndim != 2:
raise ValueError(
"X_train and X_test must both have rank 2 "
"(n_samples, n_features)"
)
if x_train.shape[1] != x_test.shape[1]:
raise ValueError("X_train and X_test must have the same feature count")The record also checks that both partitions are nonempty, that target lengths match sample counts, that timestamp indices have the expected lengths, and that training and test indices do not overlap. Those checks protect the chronological split required by the reproduction plan. They do not determine the split ratio; the surrounding configuration makes the Table 1 choice of 80/20 explicit while documenting the paper's conflicting generic 75/25 recommendation.
The sequence counterpart uses the same idea with one additional axis:
@dataclass
class SequenceSplit:
"""Chronological train/test windows for recurrent models.
Each feature tensor has shape ``(n_sequences, lookback, n_features)``. The
target timestamp denotes the future observation associated with its window;
it therefore has one label per sequence rather than one label per timestep.
"""
X_train: np.ndarray
X_test: np.ndarray
y_train: np.ndarray
y_test: np.ndarray
train_index: pd.Index
test_index: pd.Index
def __post_init__(self) -> None:
x_train = _as_numeric_array(self.X_train, "X_train")
x_test = _as_numeric_array(self.X_test, "X_test")
if x_train.ndim != 3 or x_test.ndim != 3:
raise ValueError(
"X_train and X_test must both have rank 3 "
"(n_sequences, lookback, n_features)"
)
if x_train.shape[1:] != x_test.shape[1:]:
raise ValueError(
"train and test sequence tensors must share lookback and feature dimensions"
)Here x_train.shape[1:] represents (lookback, n_features). The two partitions must agree on those dimensions even though they contain different numbers of sequences. The sequence indices identify target timestamps, not every timestamp inside each window. This distinction becomes essential later when RF and XG-Boost predictions are aligned with LSTM predictions: forming windows can remove early rows, so component predictions cannot be combined by position alone.
Prediction and preprocessing provenance
PreprocessingState stores fitted transformation information such as the feature scaler, target scaler, scaler name, feature count, and number of rows used for fitting. The paper mentions scaling but does not specify the scaler type, fit scope, or reporting scale. The generated record therefore preserves those choices instead of hiding them inside a model adapter. A training-only fit scope is a leakage-control requirement: a scaler must not learn distribution information from the held-out period.
PredictionBundle couples a model name, a pandas index, and one prediction per index label. Its contract accepts values shaped (n,) or (n, 1), but requires the number of values to equal the number of timestamps and rejects non-finite values. This is more than defensive programming. A prediction without its timestamp is not enough for the ensemble method stacked_rf_xgboost_lstm, because the RF, XG-Boost, and LSTM outputs must refer to the same target observations before they are combined.
MetricRecord performs the final identity bookkeeping: it stores the stock, model, RMSE, R2, and reporting scale. The metric value alone is insufficient for an experiment comparing two securities and several models. A record must say what was measured and on which scale.
A common tabular model interface
The conventional models share a small protocol in src/stock_forecasting/models/base.py. RegressorProtocol requires fit and predict; validate_regression_arrays checks the rank, numeric type, finiteness, and sample alignment of tabular data:
@runtime_checkable
class RegressorProtocol(Protocol):
"""Protocol implemented by tabular regression model adapters.
Implementations consume a rank-2 feature matrix with shape
``(n_samples, n_features)`` and a one-dimensional target vector with shape
``(n_samples,)``. ``fit`` returns the fitted estimator to support the
conventional ``model.fit(...).predict(...)`` usage.
"""
def fit(self, X: np.ndarray, y: np.ndarray) -> RegressorProtocol:
"""Fit the regressor and return the fitted model."""
def predict(self, X: np.ndarray) -> np.ndarray:
"""Return one continuous prediction for each input row."""This contract allows the SVR, MLPR, KNN, random-forest, and XG-Boost adapters to be orchestrated consistently while keeping their library-specific settings local. It does not make the models equivalent: their kernels, neural architectures, neighbor rules, tree construction, and boosting behavior remain different. It simply ensures that the training runner can request a fit and then obtain one prediction per evaluation row.
Worked example: tracing two batches
Suppose preprocessing produces 240 tabular training rows with four selected features. The tabular contract is X_train.shape == (240, 4) and y_train.shape == (240,). A conventional model can fit this representation directly and return, for example, one prediction for every held-out timestamp.
Suppose the same feature stream is converted into windows with a look-back of 20. The recurrent representation is no longer (240, 4). It is shaped like (n_sequences, 20, 4), and each sequence target has a timestamp after its input window. The exact n_sequences depends on the configured horizon and the rows available after splitting; the paper does not specify the look-back or horizon, so these remain explicit implementation decisions.
In both cases, the index is part of the contract. A tabular prediction bundle might contain one value for each test timestamp. A sequence prediction bundle may begin later because its first valid target requires a complete history. Before ensemble combination, the alignment layer must intersect and order these timestamp sets. The resulting component matrix has three columns—one each for random forest, XG-Boost, and LSTM—and one row per common target timestamp.
The planned tests describe these shape, time-order, and alignment invariants, but they are not evidence of completed checks. Under the run policy, local static verification, semantic code verification, test generation, and code execution were disabled. Thus the package layout and contracts presented here explain the intended implementation boundaries; they do not claim that the generated code ran or that any Table 2 result was reproduced.
Data acquisition and provenance
How can a stock-forecasting experiment be reproduced if the paper names securities but does not provide the exact download identifiers? Treat the data request itself as part of the experiment. A model input is not merely “TAINIWALCHM data”; it is a specific ticker, start date, end date, interval, returned column set, and timestamp policy.
The paper identifies Yahoo Finance as the source for the two reported securities. It describes TAINIWALCHM as covering 2014–2023 and AGROPHOS as covering 2018–2023, subject to ticker availability and API behavior. However, it does not supply the exact Yahoo Finance ticker symbols, endpoint inclusivity, interval semantics, client version, or complete returned schema. One passage also incorrectly associates the AGROPHOS period with TAINIWALCHM. The implementation records these facts as unresolved configuration rather than silently guessing.
Make the request explicit
The generated DataSourceConfig groups the four essential request fields. Here, ticker identifies the security, start and end delimit the requested historical period, and interval selects the sampling frequency, such as daily data. These values are validated before a client is allowed to make a request.
@dataclass(frozen=True, slots=True)
class DataSourceConfig:
"""Describe one explicit historical-data request.
``ticker`` is deliberately required from the caller because the paper does
not provide the exact Yahoo Finance symbols for TAINIWALCHM or AGROPHOS.
Date endpoint inclusivity and interval semantics remain properties of the
selected Yahoo Finance client.
"""
ticker: str
start: str
end: str
interval: strThis is an implementation contract, not an equation or a setting recovered from the paper. It prevents a convenience default from selecting an unintended security. The configuration also rejects an empty ticker, malformed ISO date strings, and an end value that is not later than the start value. The exact meaning of the end boundary still belongs to the selected client and should be recorded with the snapshot.
Yahoo Finance as an opt-in boundary
YahooFinanceClient.download_historical_data is responsible for one explicit request. It imports the optional yfinance dependency only when the method is called, downloads one ticker, rejects an unavailable or malformed response, normalizes the timestamp index, and attaches provenance. The public convenience function download_historical_data delegates to this client; neither function substitutes a ticker.
A focused excerpt shows the request boundary and several deliberate choices:
downloaded = yf.download(
tickers=config.ticker,
start=config.start,
end=config.end,
interval=config.interval,
auto_adjust=False,
progress=False,
threads=False,
)The paper specifies Yahoo Finance as the source but does not specify auto_adjust, progress behavior, threading, or the client API. Consequently, auto_adjust=False and the other arguments are implementation decisions that must be preserved in the provenance record if the resulting data is used. They are not claims about the original experiment.
The client then stores the request metadata on the returned timestamp-indexed DataFrame:
provenance: dict[str, Any] = {
"source": "Yahoo Finance",
"client": "yfinance",
"ticker": config.ticker,
"start": config.start,
"end": config.end,
"interval": config.interval,
"auto_adjust": False,
}
frame.attrs["yahoo_finance_provenance"] = provenance
return frameA timestamp-indexed DataFrame has one observation per row and a DatetimeIndex identifying when that observation occurred. Chronological order is an invariant: the returned index must be increasing, and duplicate timestamps are rejected rather than merged with an undocumented rule. This matters because later train/test splitting and sequence construction depend on temporal order. A successful API response is not automatically valid experimental input; an empty result, ambiguous multi-ticker response, malformed timestamp, or duplicate timestamp raises YahooFinanceAcquisitionError.
The acquisition wrapper also does not claim to resolve market-data subtleties. Missing trading days may be normal rather than errors, while corporate-action adjustments, timezone conversion, endpoint inclusivity, and Yahoo Finance client versions can change the resulting table. Those choices belong in the data snapshot and its manifest.
Offline alternatives: CSV and synthetic data
The repository provides two offline sources. load_stock_csv reads a user-supplied snapshot, parses a named date column, rejects unparseable or duplicate timestamps, and returns a sorted DataFrame. This is useful when a Yahoo Finance snapshot has already been obtained, but CSV loading is an implementation addition; it is not the paper's reported acquisition procedure.
from pathlib import Path
from stock_forecasting.data.csv import load_stock_csv
raw = load_stock_csv(Path("data/sample_stock.csv"), "Date")The CSV loader deliberately stops at structural loading. Feature selection, null handling, zero-value policy, and target selection remain downstream decisions. This keeps the source adapter from silently changing the observations before the configured preprocessing stage sees them.
For a fully offline demonstration, make_synthetic_stock_data creates deterministic OHLCV-shaped data. It returns the columns Open, High, Low, Close, Adj Close, and Volume with a unique chronological business-day index. The generator validates positive finite values and basic OHLC ordering before returning.
from stock_forecasting.data.synthetic import make_synthetic_stock_data
raw = make_synthetic_stock_data(300, seed=7)This worked example provides 300 rows with a repeatable random seed. It is suitable for demonstrating data flow, configuration, and shape contracts, but it cannot reproduce the paper's Yahoo Finance observations or Table 2 metrics. The generated price process, start date, business-day frequency, and initial value are all synthetic implementation choices.
The same workflow with a real request
Once an exact user-supplied ticker is known, the equivalent explicit configuration is:
from stock_forecasting.config import DataSourceConfig
from stock_forecasting.data.yahoo import YahooFinanceClient
request = DataSourceConfig(
ticker="EXPLICIT_TICKER",
start="2014-01-01",
end="2023-12-31",
interval="1d",
)
raw = YahooFinanceClient().download_historical_data(request)EXPLICIT_TICKER is intentionally a placeholder in this tutorial example: the supplied paper context does not identify the correct Yahoo Finance symbol, and substituting one would be an unsupported factual claim. For an AGROPHOS request, the caller would likewise provide the verified ticker and the reported 2018–2023 period. The data source, request values, returned columns, and row count should be saved alongside the CSV rather than inferred later.
The repository's scripts/download_yahoo_snapshot.py provides that opt-in command-line boundary. It requires --ticker, --start, and --end, so a download cannot accidentally use a hidden security default:
parser.add_argument(
"--ticker",
required=True,
help="Exact Yahoo Finance ticker identifier; no default is provided.",
)After acquisition, the script writes a CSV and a JSON sidecar containing the request and retrieval metadata. It checks that the frame is nonempty, chronological, and free of duplicate timestamps before writing. A typical command therefore has to make the unresolved data decision visible:
python scripts/download_yahoo_snapshot.py \
--ticker EXPLICIT_TICKER \
--start 2014-01-01 \
--end 2023-12-31 \
--interval 1d \
--output data/yahoo_snapshot.csvThis command is an interface example, not a report that a download occurred. Under the current run policy, no API call, local execution, or data validation was performed.
What provenance protects
Provenance is the source configuration attached to a loaded dataset: at minimum, the source name, client, ticker, dates, interval, and relevant adjustment settings. It allows a later reader to distinguish a changed download from a changed model. It also exposes why exact numerical reproduction is unavailable here: the paper omits the exact ticker symbols and several data-handling details, and no original snapshot is included.
The acquisition stage therefore establishes only a trustworthy boundary. It does not decide whether the target is Close or Adj Close, which OHLCV columns become features, how nulls or zero values are handled, or how sequences are formed. Those decisions belong to the next preprocessing stage and must remain explicit. Synthetic and CSV inputs make the package usable offline, while the Yahoo Finance path preserves the paper's stated source when the required identifier and optional dependency are supplied.
Cleaning, feature selection, splitting, and scaling
How do we turn a timestamped stock table into fair training examples without allowing information from the future to influence the past? The key is to separate four responsibilities: clean the raw rows, choose the inputs and target explicitly, split observations chronologically, and fit learned transformations only on the training portion.
This section implements the preprocess_time_series method through prepare_stock_data in src/stock_forecasting/preprocessing.py. The paper mentions OHLCV data and possible secondary inputs, but it does not state the exact feature set. It also does not identify whether the target is Close, Adjusted Close, or another value. Those choices therefore belong in PreprocessingConfig, rather than being inferred from the review text.
1. Validate and clean the time series
The generated pipeline expects a pandas DataFrame with a unique, ascending DatetimeIndex. Each row represents one observation, such as one trading day. A timestamp index is important because a positional split is meaningful only when the rows are already in chronological order.
The paper's general pipeline calls for removing redundancies and null values. The implementation makes those operations deterministic: clean_stock_table sorts the index, keeps the last row for a duplicate timestamp, and removes rows containing null values. It also accepts a remove_zero_values argument. In the composed pipeline, that argument is explicitly set to False, because the paper does not say which zero values are invalid. A zero volume, for example, may have a different interpretation from a zero price.
The core cleaning excerpt is:
cleaned = frame.copy(deep=True).sort_index()
cleaned = cleaned.loc[~cleaned.index.duplicated(keep="last")]
cleaned = cleaned.dropna(axis=0, how="any")
numeric_columns = [
column for column in cleaned.columns if is_numeric_dtype(cleaned[column].dtype)
]
if remove_zero_values and numeric_columns:
zero_rows = (cleaned.loc[:, numeric_columns] == 0).any(axis=1)
cleaned = cleaned.loc[~zero_rows]This excerpt is from src/stock_forecasting/data/validation.py, in clean_stock_table. Notice that the function does not silently drop a target column and does not decide that every zero is an error. After cleaning, it checks that the result is nonempty, chronologically ordered, unique by timestamp, and finite in its numeric columns.
validate_stock_table performs the stricter downstream check. It verifies that required columns exist, are numeric, contain no nulls, and contain only finite values. Its required_columns argument is normally the union of the configured features and target. A malformed table fails early rather than producing a confusing model error later.
2. Select X and y explicitly
Let X denote the feature table and y denote the regression target. In the tabular representation, X has shape (n_observations, n_features), while y has shape (n_observations,). Their timestamp indices must be identical so that each feature row refers to the same target row.
The generated select_features_and_target function does not guess columns from names such as “OHLCV.” Instead, it receives a PreprocessingConfig containing feature_columns and target_column. This is an implementation decision forced by an omission in the paper: OHLCV and secondary data are discussed, but the Section 4 feature set is not specified.
A focused part of the function is:
required_columns = config.feature_columns + (config.target_column,)
# Boundary validation enforces the timestamp, null, numeric, and finite
# value invariants before the configured projection is constructed.
validate_stock_table(frame, required_columns)
if config.target_column in config.feature_columns:
raise ValueError("target_column must not also be a feature column")
features = frame.loc[:, list(config.feature_columns)].astype(
np.float64, copy=True
)
target = frame.loc[:, config.target_column].astype(np.float64, copy=True)This excerpt comes from src/stock_forecasting/data/features.py. The check preventing the target from also appearing as a feature is a leakage safeguard: including the value being predicted among the inputs would make the supervised task ill-defined.
For the worked example, choose the following configuration explicitly:
feature_columns = ("Open", "High", "Low", "Volume")
target_column = "Close"
lookback = 20
horizon = 1
scaler_name = "standard"
train_fraction = 0.8These are not recovered facts about the paper's original experiment. They are illustrative reproduction decisions. The target could instead be Adjusted Close, and the paper does not establish which one was used. Likewise, the look-back and horizon are absent from the paper.
3. Split chronologically
The primary reproduction choice is an 80/20 chronological split because Table 1 specifies 80% training and 20% testing for the proposed ensemble. The paper's generic pipeline separately mentions 75/25, so the code does not hide that conflict: callers pass train_fraction explicitly.
chronological_split first checks that X and y are aligned, nonempty, unique, and already sorted. It then slices by position without shuffling:
boundary = int(len(X) * fraction)
if boundary <= 0 or boundary >= len(X):
raise ValueError(
"train_fraction produces an empty training or test partition; "
"provide more observations or a different fraction"
)
X_train = X.iloc[:boundary].copy()
X_test = X.iloc[boundary:].copy()
y_train = y.iloc[:boundary].copy()
y_test = y.iloc[boundary:].copy()This is the implementation in src/stock_forecasting/data/splitting.py. The resulting X_train and y_train contain earlier observations, while X_test and y_test contain later observations. The function also checks that the final training timestamp precedes the first test timestamp.
A validation split, when tuning is enabled, is made only inside the training portion by chronological_train_validation_split. Its API does not accept test data. That boundary is deliberate: a test target must not influence feature transformation, hyperparameter selection, or model construction.
4. Fit scaling state on training rows only
Scaling changes the numerical representation of features. With standard scaling, for example, a feature is centered and rescaled using statistics estimated from data. The exact scaler type and metric scale are not specified by the paper, so the generated code supports None, "standard", and "minmax" as explicit choices.
The important invariant is not the particular scaler; it is the fitting scope. fit_scalers receives X_train and y_train, fits the feature and target transformations there, and returns a PreprocessingState. The test rows are transformed later using that already-fitted state:
state = fit_scalers(X_train, y_train, config.scaler_name)
X_train_scaled = transform_features(state, X_train)
X_test_scaled = transform_features(state, X_test)
y_train_scaled = transform_target(state, y_train).reshape(-1)
y_test_scaled = transform_target(state, y_test).reshape(-1)This excerpt is from src/stock_forecasting/preprocessing.py. Fitting a scaler on the complete dataset would allow the distribution of the later test period to affect the representation of earlier training data. That is a form of temporal leakage, even though the target values are not directly copied into the features.
If predictions are reported in the original price units, inverse_transform_target can undo the target transformation. The code exposes that operation rather than assuming whether the paper's RMSE and R2 were computed on scaled or original values. Predictions and targets must always be compared on the same scale.
5. Construct LSTM windows
Tabular models consume independent-looking rows. An LSTM instead consumes ordered windows. The sequence representation, called X_seq here, has shape (n_sequences, lookback, n_features). Each sequence contains lookback historical rows, and its associated target is located horizon steps after the end of that window.
For example, with lookback=20 and horizon=1, the first window contains 20 observations and predicts the immediately following target. The initial rows that cannot form a complete window are therefore not represented as sequence examples. This is why sequence predictions generally have fewer timestamps than tabular predictions.
The generated constructor documents and enforces that relationship:
sequence_features[sequence_number] = features[
sequence_number : sequence_number + lookback
]
sequence_targets[sequence_number] = targets[target_position]
target_labels.append(ordered_timestamps[target_position])This excerpt comes from src/stock_forecasting/data/sequences.py. target_position is computed after the input window, so the window does not contain observations from after its target. The target timestamps are retained in SequenceSplit.train_index and SequenceSplit.test_index; those labels are needed later when RF, XG-Boost, and LSTM predictions are aligned.
The generated make_sequences function uses the Table 1 80/20 ratio because its public signature has no split-ratio argument. In the composed prepare_stock_data pipeline, the constructed windows are re-partitioned using the configured train_fraction, so the preprocessing configuration remains the authoritative record of the split decision.
6. Compose the preparation pipeline
prepare_stock_data connects the stages in this order:
Clean the timestamp-indexed table while retaining zero values by explicit policy.
Select the configured features and target.
Split tabular observations chronologically.
Fit optional feature and target scalers on training rows only.
Transform training and test tabular arrays with that state.
Construct ordered sequence windows from the transformed series.
Return both representations, their indices, the preprocessing state, and an audit dictionary.
The result is a PreparedStockData record. Its tabular field contains rank-2 arrays for classical regressors, and its sequences field contains rank-3 arrays for the LSTM. The audit dictionary records choices such as feature columns, target column, split fraction, look-back, horizon, scaler, zero policy, and the paper's 80/20 versus 75/25 conflict.
A complete illustrative setup is therefore conceptually:
config = PreprocessingConfig(
feature_columns=("Open", "High", "Low", "Volume"),
target_column="Close",
train_fraction=0.8,
lookback=20,
horizon=1,
scaler_name="standard",
)
prepared = prepare_stock_data(raw_frame, config)
print(prepared.tabular.X_train.shape)
print(prepared.sequences.X_train.shape)
print(prepared.audit["target_column"])The exact PreprocessingConfig constructor and PreparedStockData record are defined by the generated package; the excerpt illustrates the public data flow and the explicit decisions it must carry. For a sufficiently long input, the first printed shape is rank 2, while the second is rank 3 with its middle dimension equal to 20 and its final dimension equal to the number of configured features.
The main lesson is that preprocessing is part of the model specification. The paper does not provide enough detail to recover its original target, features, scaler, look-back, horizon, or zero policy. This implementation keeps those choices visible, preserves chronological alignment, and prevents learned transformations from using the held-out period. No code execution, API call, test run, or independent numerical verification was performed under the run policy.
Classical regression baselines
Which models should receive the same stock-price rows before the LSTM and ensemble are considered? The paper's Section 4 comparison uses five tabular regressors: support-vector regression (SVR), multilayer perceptron regression (MLPR), K-nearest-neighbor regression (KNN), random-forest regression, and XG-Boost regression. Each model consumes the same training features and produces one continuous prediction for each evaluation row.
In the notation used here, X_train is a rank-2 feature matrix with shape (n_train, n_features), and y_train is a one-dimensional target array with shape (n_train,). X_eval has shape (n_eval, n_features) and must use the same feature order and preprocessing as X_train. The resulting prediction vector has shape (n_eval,). This common contract makes the models comparable and allows their predictions to be aligned later for the Random Forest + XG-Boost + LSTM ensemble.
What the paper specifies—and what it does not
Paper fact. The paper includes SVR, MLPR, KNN, random forest, and XG-Boost in its reported comparison. It describes random forest as a bagging ensemble of decision trees: multiple trees are trained and their regression outputs are aggregated. It describes XG-Boost as sequential gradient boosting: weak learners are added iteratively while later learners respond to earlier errors through gradient-based updates.
Implementation decision. The generated package wraps established Python estimators behind a small local protocol. The paper does not specify the SVR kernel or regularization, the MLPR architecture, the KNN neighbor count, the selected random-forest settings, the selected XG-Boost settings, or the search procedure. Consequently, these values remain constructor arguments or explicit configuration rather than being presented as recovered paper settings.
The paper does provide candidate values for the random forest and XG-Boost components. These are search candidates, not selected configurations. A candidate grid says which values may be tried; it does not say which value produced the paper's reported result.
One shared fit/predict contract
The RegressorProtocol in src/stock_forecasting/models/base.py defines the boundary used by the tabular adapters. Its responsibility is deliberately small: fit receives training arrays and returns a fitted model, while predict receives rank-2 evaluation features and returns one prediction per row. The accompanying validate_regression_arrays function checks numeric dtype, rank, nonempty dimensions, finite values, and sample-count agreement.
The central contract is visible in this focused excerpt:
@runtime_checkable
class RegressorProtocol(Protocol):
"""Protocol implemented by tabular regression model adapters.
Implementations consume a rank-2 feature matrix with shape
``(n_samples, n_features)`` and a one-dimensional target vector with shape
``(n_samples,)``. ``fit`` returns the fitted estimator to support the
conventional ``model.fit(...).predict(...)`` usage.
"""
def fit(self, X: np.ndarray, y: np.ndarray) -> RegressorProtocol:
"""Fit the regressor and return the fitted model."""
def predict(self, X: np.ndarray) -> np.ndarray:
"""Return one continuous prediction for each input row."""This is more than defensive programming. If one adapter silently accepts a differently ordered feature matrix, returns a column matrix instead of a vector, or drops rows, the comparison and eventual ensemble can become invalid while still appearing to run. The protocol does not decide which model is best; it ensures that each model receives and returns data in a known form.
The individual adapters
SVRRegressor wraps sklearn.svm.SVR. Its configurable settings include kernel, C, gamma, epsilon, and related estimator parameters. The adapter intentionally leaves feature scaling external. Scaling may be important for SVR, but the paper does not specify a scaler or its fitting scope, so that choice belongs to the preprocessing configuration rather than this model class. Calling predict before fit, changing the feature count, or producing non-finite outputs raises an explicit error.
MLPRRegressor wraps scikit-learn's multilayer perceptron regressor and exposes hidden_layer_sizes, activation, solver, optimization settings, iteration count, and random state. The paper reports an MLPR result but does not describe its hidden layers or training configuration. The generated adapter therefore does not claim that its ordinary defaults reproduce the paper's MLPR.
KNN is also configurable. The relevant choices include n_neighbors, the weighting rule, and the distance metric. A neighbor count must be positive and cannot exceed the number of training rows. KNN is particularly sensitive to feature scale because distances are computed in feature space: a high-magnitude column can dominate a lower-magnitude column unless preprocessing addresses that issue. Tree-based models generally respond differently to scale because their splits compare feature values rather than relying directly on geometric distance.
The paper's KNN distance record is identified as eq_2, but its canonical LaTeX is empty because the extracted expression is OCR-damaged. The supplied symbol record describes D as a nonnegative distance between observations, h_i as a data value or observation, p_r as a predicted or query value, n as the number of dimensions or terms, and l as a summation index. The exact vector components, square-root formatting, and index placement are unclear. Therefore, no equation block is reproduced here and no formula is reconstructed.
Instead, euclidean_distance_eq_2 documents the implementation mapping. It accepts two finite rank-1 vectors with equal feature dimensions and computes a standard vector Euclidean distance. The generated file explicitly labels this as a coding decision rather than recovered LaTeX:
def euclidean_distance_eq_2(left: np.ndarray, right: np.ndarray) -> float:
"""Return the vector Euclidean distance associated conceptually with eq_2.
The paper's canonical eq_2 record has no LaTeX because its extraction is
damaged. This vector-norm implementation is therefore an explicit coding
decision, not a reconstruction of the missing equation.
"""Random forest and XG-Boost candidate grids
The generated random-forest adapter exposes n_estimators, max_depth, max_features, bootstrap behavior, and random-state settings. Here, n_estimators is the number of trees, max_depth limits tree depth, and max_features controls the feature subset considered by a split. The paper's Table 1 candidate values are returned exactly by random_forest_candidates:
def random_forest_candidates() -> dict[str, tuple[object, ...]]:
"""Return the random-forest candidates listed in Table 1.
These are candidate values reported by the paper, not selected values. The
paper does not specify the search procedure or the configuration ultimately
used for its reported results.
"""
return {
"n_estimators": (50, 100, 200),
"max_depth": (3, 5, 7),
"max_features": ("sqrt", "log2"),
}For XG-Boost, learning_rate controls the contribution of each boosting step, n_estimators is the number of boosting estimators, and max_depth controls the depth of the tree learners. The paper lists max_depth values of 3, 4, and 5; learning_rate values of 0.1, 0.01, and 0.001; and n_estimators values of 50, 100, 150, 500, and 1000. The xgboost_candidates function preserves those values, while XGBoostRegressor keeps the package dependency, objective, random state, and job count explicit implementation choices.
The combined grid interface makes the distinction auditable:
def paper_candidate_grids() -> dict[str, dict[str, tuple[object, ...]]]:
"""Return the Random Forest and XG-Boost candidate grids from Table 1.
Returns
-------
dict[str, dict[str, tuple[object, ...]]]
Fresh mappings containing the paper-listed candidate values under the
keys ``"random_forest"`` and ``"xgboost"``.
No selected hyperparameters are inferred, and no tuning procedure is
performed here. The values are obtained from the owning model modules so
that the candidate definitions remain centralized.
"""paper_candidate_grids is therefore a fidelity utility, not a claim that tuning has happened. The separate tuning layer can choose a search and validation policy, but those choices are absent from the paper and must be recorded if used.
Constructing models without hiding their identity
build_regressor accepts canonical names such as "svr", "mlpr", "knn", "random_forest", and "xgboost", along with selected aliases such as "XG-Boost" and "random forest". It passes the supplied configuration directly to the corresponding adapter. It rejects LSTM and ensemble requests because those require rank-3 sequence inputs and separate orchestration, respectively.
A small comparison setup can therefore be written as follows:
from stock_forecasting.models.factory import build_regressor
model_configs = {
"svr": {"kernel": "rbf", "C": 1.0},
"mlpr": {"hidden_layer_sizes": (32,), "random_state": 7},
"knn": {"n_neighbors": 5, "weights": "distance"},
"random_forest": {
"n_estimators": 100,
"max_depth": 5,
"max_features": "sqrt",
"random_state": 7,
},
"xgboost": {
"max_depth": 3,
"learning_rate": 0.1,
"n_estimators": 100,
"random_state": 7,
},
}
models = {
name: build_regressor(name, config)
for name, config in model_configs.items()
}
predictions = {}
for name, model in models.items():
model.fit(X_train, y_train)
predictions[name] = model.predict(X_eval)In this worked example, X_train and y_train must come from the earlier chronological preprocessing stage, and X_eval must use the identical feature order and learned preprocessing state. Each entry in predictions should contain one value for every row in X_eval. The particular settings above are illustrative implementation choices; they are not reported as the paper's selected hyperparameters.
Bagging versus boosting
Random forest and XG-Boost are both ensembles, but they combine trees differently. Random forest uses bagging: multiple trees are trained with randomized data or feature choices, and their predictions are aggregated to reduce dependence on any one tree. XG-Boost uses sequential boosting: later learners are fitted in response to the current ensemble's errors. This distinction matters when interpreting the models and when documenting tuning parameters. A larger forest changes the number of independently randomized trees, while a larger boosted estimator count extends a sequence of corrective learners.
The classical_regressors_forward and fit_models_and_generate_predictions method contracts keep these baseline predictions separate until evaluation or explicit ensemble construction. That separation prevents the tabular adapters from silently deciding how the paper's later RF + XG-Boost + LSTM combination should work. The paper calls that model stacked, but its actual wiring remains unspecified; alignment and combination are handled in the following ensemble stage.
Finally, the generated files and planned tests were not executed or independently verified under this run policy. The candidate grids and adapter contracts described here are implementation artifacts intended for static and semantic review, not evidence that the paper's Table 2 values have been reproduced.
The two-layer LSTM regressor
How is an LSTM different from the tabular regressors described earlier? A tabular model receives one row of features at a time. An LSTM receives an ordered window of rows, so it can model relationships across time. In this implementation, one input has rank 3 and shape (batch, sequence_length, n_features): batch is the number of sequences processed together, sequence_length is the look-back window, and n_features is the number of selected input columns.
The paper specifies two LSTM layers, dropout of 0.2, a dense layer with 25 units, the Adam optimizer, MSE loss, batch size 32, and a maximum of 50 epochs. It does not specify the look-back length, recurrent-unit count, exact return-sequence settings, validation behavior, or final output-layer details. The generated implementation therefore records those missing values as implementation choices. It uses 32 recurrent units, returns sequences from the first layer, uses the second layer's final representation, and ends with a linear single-output layer for regression.
What the extracted gate equations tell us
An LSTM cell uses gates to regulate information. The input gate controls information entering the memory, the forget gate controls information retained from the previous state, and the output gate controls information exposed as the current representation. The paper supplies one displayed expression for each of these gate activations, but not a complete recurrence.
The first supplied expression represents the input-gate activation. It is reproduced exactly from canonical record eq_8.
Here, iga is the input-gate activation, represented as a vector or tensor. σ is the elementwise sigmoid activation. Wip is the input-gate weight matrix, although its dimensions are not supplied. ht−1 is the previous hidden state, and Xc is the current input as printed in the paper; both are described as vectors or tensors. bi is the input-gate bias vector. In practical terms, the gate produces one value per gate unit, conventionally between 0 and 1 because of the sigmoid.
The helper input_gate_eq_8 in src/stock_forecasting/models/lstm.py gives this record a named implementation boundary. It does not claim that the extracted expression is a complete cell equation. The helper validates rank-2 step tensors, concatenates the previous hidden state and current input, applies a matrix multiplication and bias, and then applies a sigmoid through TensorFlow.
The forget-gate record is eq_9.
fga denotes the forget-gate activation. σ again denotes the elementwise sigmoid. Wf g is the printed forget-gate weight matrix, while ht−1 and Xc denote the previous hidden state and current input. b f is the forget-gate bias vector. As with the input gate, the expression describes a gate activation rather than the complete update of the LSTM cell state.
The generated function forget_gate_eq_9 maps this paper record to the same framework-level affine-and-sigmoid pattern as the input gate. The separate function name is useful for traceability: readers can connect the implementation to eq_9 without treating the OCR-damaged notation as a complete executable specification.
The output-gate record is eq_10.
Opg is the output-gate activation, σ is the sigmoid, Wop is the output-gate weight matrix, and bo is the output-gate bias. The same ht−1 and Xc symbols represent the previous hidden state and current input. The supplied record gives no matrix dimensions, candidate-cell expression, cell-state update, or hidden-state update.
The corresponding output_gate_eq_10 helper therefore exposes only the gate-level mapping. The actual recurrent computation is delegated to Keras rather than reconstructed from incomplete extracted equations. This is important: implementing the missing equations from memory would create a new mathematical specification rather than reproduce the supplied record.
Building the two-layer network
The core network construction is concentrated in build_lstm_regressor. The excerpt below is copied from src/stock_forecasting/models/lstm.py:
inputs = keras.Input(shape=input_shape, name="stock_sequence")
# Table 1: first LSTM layer, followed by dropout 0.2 in the paper configuration.
recurrent = keras.layers.LSTM(units, return_sequences=True, name="lstm_1")(inputs)
recurrent = keras.layers.Dropout(float(dropout), name="dropout_1")(recurrent)
# Table 1: second LSTM layer; returning its final state is an explicit wiring choice.
recurrent = keras.layers.LSTM(units, return_sequences=False, name="lstm_2")(recurrent)
recurrent = keras.layers.Dropout(float(dropout), name="dropout_2")(recurrent)
dense = keras.layers.Dense(dense_units, activation="relu", name="dense_25")(recurrent)
outputs = keras.layers.Dense(1, activation="linear", name="price_output")(dense)
return keras.Model(inputs=inputs, outputs=outputs, name="two_layer_lstm_regressor")The input shape excludes the batch dimension, so input_shape is (sequence_length, n_features). Keras adds the batch dimension at runtime, producing rank-3 inputs. The first LSTM uses return_sequences=True, which preserves one recurrent output for every position in the window and supplies a sequence to the second LSTM. The second layer uses return_sequences=False, so it returns one final representation per input sequence. The generated file explicitly labels this return-sequence wiring as a choice because the paper does not state it.
Dropout is applied after each LSTM layer using the configured probability. The paper specifies 0.2; the code keeps the value configurable so that the unresolved choice is visible rather than hidden. The dense layer has 25 units, matching Table 1, and uses a ReLU activation. The final dense layer has one unit and a linear activation. That final linear output is a reasonable regression decision, but the paper does not explicitly provide the output-layer activation.
The recurrent-unit count is another implementation decision. The generated LSTMRegressor defaults to 32 units per LSTM layer, but this number should not be described as recovered from the paper. The paper specifies the number of layers and the dense width, not the number of recurrent units.
Training with MSE and Adam
The LSTM adapter enforces the sequence contract before training. Its X input must be a numeric rank-3 array with shape (batch, sequence_length, n_features). Its target y may have shape (batch,) or (batch, 1), but it must contain exactly one target per sequence. The adapter reshapes targets to (batch, 1) before passing them to Keras.
The training configuration is visible in this excerpt from LSTMRegressor.fit:
self.model_.compile(optimizer="adam", loss="mse")
fit_kwargs: dict[str, Any] = {
"x": X_array,
"y": y_array,
"epochs": self.max_epochs,
"batch_size": self.batch_size,
"verbose": self.verbose,
"shuffle": False,
}
if self.validation_fraction is not None:
fit_kwargs["validation_split"] = float(self.validation_fraction)
self.history_ = self.model_.fit(**fit_kwargs)The optimizer and loss match the paper's stated settings. The adapter caps max_epochs at 50 and defaults batch_size to 32. Shuffling is disabled as an implementation safeguard for ordered sequences. A validation split is optional and is not a paper-specified value. If used, it must be recorded in the experiment manifest rather than mistaken for a setting recovered from Section 4.
MSE is the paper-specified neural loss, but the supplied equation records contain no canonical MSE LaTeX. Consequently, src/stock_forecasting/losses.py does not display or invent a formula. mean_squared_error_loss uses TensorFlow's squared-difference operation followed by mean reduction, while validate_loss_inputs checks that prediction and target tensors have matching shapes and finite floating-point values.
Worked shape example
Suppose preprocessing selects four features, chooses a look-back of 20 observations, and creates a batch of 32 sequences. The LSTM input then has shape (32, 20, 4). Each sequence corresponds to one future target, so the target array has shape (32, 1) during model training. The network returns (32, 1), meaning one continuous forecast for each sequence in the batch.
The look-back value of 20 in this example is not supplied by the paper; it is an explicit reproduction decision. The sequence builder in src/stock_forecasting/data/sequences.py preserves target timestamps and ensures that each window precedes its target. Initial observations are lost because a complete look-back window must exist before the first sequence can be formed. Those shortened sequence indices must later be aligned with the random-forest and XG-Boost predictions before ensemble construction.
Why the framework implementation matters
A hand-written LSTM cell would need the candidate-cell computation, cell-state update, and hidden-state update in addition to the three gate expressions. Those pieces are absent from eq_8, eq_9, and eq_10, and the extracted notation itself contains unresolved formatting such as Xc and spaced subscripts. The generated code therefore uses TensorFlow/Keras for the complete recurrent mechanism and keeps the three named helpers as traceability mappings, not as a claim of a newly reconstructed cell.
This choice also preserves the boundary between paper facts and implementation decisions. The two LSTM layers, dropout 0.2, dense width 25, Adam, MSE, batch size 32, and maximum 50 epochs come from the paper's Table 1 description. The recurrent units, look-back, final linear output, return-sequence behavior, dropout placement, validation split, and scaling behavior are choices required to make a runnable framework. The generated files document those choices, but no LSTM training, code execution, syntax check, or independent verification was performed under the run policy.
Training orchestration and leakage-aware tuning
How can model selection remain fair when stock observations arrive in time order? Treat the test set as a final exam: it may be used to measure the finished models, but not to choose their settings. The generated implementation separates three roles:
X_trainandy_trainare the features and targets available for fitting.X_validationandy_validationare a later portion of the training data used to compare candidate configurations.X_testandy_testare the held-out observations reserved for final evaluation.
The paper describes hyperparameter tuning and lists candidate values for random forest and XG-Boost, but it does not specify the search algorithm, validation fraction, scoring rule, random seed, or repeat count. Those are therefore implementation decisions, not recovered paper facts. The primary reproduction choice still follows Table 1's chronological 80/20 split; the paper's generic pipeline separately mentions 75/25, so the split policy must remain documented.
Training settings are explicit configuration
The TrainingConfig class records the settings that affect fitting. The paper specifies batch size 32 and a maximum of 50 LSTM epochs. The seed and validation fraction are not supplied by the paper and are consequently caller choices.
@dataclass(frozen=True, slots=True)
class TrainingConfig:
"""Record reproducibility and neural-training settings.
Batch size 32 and a maximum of 50 epochs come from Table 1. The seed and
validation fraction are implementation decisions because the paper does
not specify them. ``max_epochs`` is capped at 50 so this configuration
cannot silently exceed the reported neural-training limit.
"""
seed: Optional[int]
batch_size: int = 32
max_epochs: int = 50
validation_fraction: Optional[float] = NoneA fitted model is an estimator that has already learned parameters from permitted training rows and can respond to predict. A PredictionBundle is different: it associates prediction values with their timestamps. That distinction matters because a fitted estimator has model state, whereas a prediction bundle is an auditable evaluation artifact. The runner returns both through ExperimentPredictions.
For an illustrative run, a caller can choose a nonnegative seed, retain the paper-aligned batch size and epoch cap, and provide a training-only validation fraction such as 0.2. That choice does not reproduce an unstated paper protocol; it makes the validation policy inspectable. If validation_fraction remains None, the generated runner can fit configured models without invoking the optional tabular search interface.
Candidate search uses training data only
The generated search_regressor function makes exhaustive Cartesian-grid search an explicit implementation decision. It first splits the supplied training arrays chronologically, evaluates each candidate on the validation suffix, selects the candidate with the smallest supplied score, and refits the selected estimator on all supplied training rows. The function does not accept test data.
def search_regressor(
factory: Callable[[Mapping[str, Any]], RegressorProtocol],
grid: Mapping[str, Sequence[Any]],
X_train: np.ndarray,
y_train: np.ndarray,
validation_fraction: float,
scorer: Callable[..., float],
) -> SearchResult:
"""Select and refit a regressor using a chronological training split.
Candidates are evaluated on the final ``validation_fraction`` of the
supplied training rows, while the earlier rows are used for candidate
fitting. The candidate with the smallest score is selected, which is an
explicit implementation decision appropriate for RMSE and other
loss-style scorers. The paper lists candidate values but does not specify
this search algorithm, score direction, or validation protocol.
The function intentionally has no test-data parameter. Consequently,
held-out test targets cannot enter candidate selection through this API.
After selection, a fresh estimator is fitted on all ``X_train``/``y_train``
rows so callers receive a model trained on the complete permitted sample.
"""Here, validation_fraction determines how much of the supplied training suffix is reserved for candidate comparison. The scorer is a callable that receives true and predicted validation values; the generated runner supplies an RMSE scorer, for which lower is better. factory constructs a fresh estimator for each parameter combination, preventing one candidate's fitted state from carrying into another candidate.
The returned SearchResult records selected_params, validation_score, and the refitted estimator. In a worked audit, readers should inspect selected_params and the score used for selection, but should not interpret that score as a held-out test result. The paper supplies random-forest candidates for estimators, depth, and feature selection, and XG-Boost candidates for depth, learning rate, and estimator count; it does not state which values won.
The runner coordinates model families
fit_models_and_generate_predictions is the orchestration method for one prepared stock dataset. It fits tabular models on rank-2 arrays, optionally fits the sequence model on rank-3 arrays, and creates prediction bundles for the appropriate test timestamps. Stock-specific fitting is the default: the caller supplies one prepared dataset per security rather than combining TAINIWALCHM and AGROPHOS into a single training operation.
The central isolation rule is visible in the runner's public contract:
def fit_models_and_generate_predictions(
prepared: Any,
model_configs: Mapping[str, Any],
training_config: TrainingConfig,
) -> ExperimentPredictions:
"""Fit comparison models using training data and produce held-out predictions.
The test partition is passed only to ``predict``. Optional searches use a
chronological validation suffix of the training partition, as required by the
leakage-aware search interface. Stock-specific fitting is represented by the
caller supplying one prepared dataset per stock.
"""The runner obtains tabular_split and sequence_split from prepared. It validates that tabular features are rank 2, that their row counts match their targets, and that predictions have one value per evaluation row. For each configured classical model, _fit_tabular_model either fits directly or invokes search_regressor; the test arrays are not passed into either fitting or tuning.
The LSTM receives sequence_X_train and sequence_X_test, not the tabular arrays. Its settings are passed from TrainingConfig, including max_epochs, batch_size, validation_fraction, and seed. Thus the paper's maximum of 50 epochs and batch size 32 remain visible in the training record. The recurrent architecture still contains choices that the paper does not specify, including the look-back length and recurrent unit count established elsewhere in preprocessing and model configuration.
Sequence predictions require additional alignment
A look-back window means that the first observations may not have enough historical rows to form an LSTM input. Consequently, the sequence test partition can contain fewer timestamps than the tabular test partition. The runner intersects the tabular and sequence timestamps before fitting the ensemble and before producing its combined evaluation output.
This is not merely bookkeeping. If X_test contains one row for a timestamp that the LSTM cannot represent, combining its prediction with a neighboring LSTM prediction would compare different target times. The runner therefore creates common training rows and common evaluation rows by timestamp, checks that aligned tabular and sequence targets agree, and preserves chronological order.
The final ExperimentPredictions object contains fitted models, named predictions, a target bundle, selected_configs, and training_metadata. The metadata records such details as the chronological split policy, validation fraction, seed, epoch cap, batch size, and the fact that test targets were not used for fitting. This is the practical audit trail for the worked example: inspect the selected-configuration records without reporting any numerical value as verified.
Manifest and reproducibility limits
ExperimentManifest complements the runner output by recording the data source, feature columns, target column, split policy, scaler, look-back, horizon, seed, tuning method, model configurations, and ensemble choice. Its purpose is to prevent a later reader from mistaking a default for a paper fact. In particular, the manifest can state that Table 1 is the primary reproduction target while preserving the conflicting 75/25 recommendation from the generic pipeline.
The paper does not specify early stopping, so the generated configuration should not be described as reproducing an early-stopping protocol. It also does not specify whether repeated trials were averaged, whether validation was chronological, or how ties between candidates were handled. The generated exhaustive chronological search and lower-is-better scoring rule are deliberate implementation policies. A recorded seed improves auditability, but it cannot guarantee stable model selection across libraries, hardware, dependency versions, or data snapshots.
Chronological validation is preferable here to ordinary random cross-validation because random folds can place later observations in the fitting portion while earlier observations appear in validation. That ordering would not represent the intended forecasting direction. Even chronological validation remains an approximation: market regimes can change, and a single validation suffix may not represent all future conditions.
Finally, no code execution, API request, training run, test run, syntax check, or semantic code verification occurred in this pipeline. The search and runner behavior described here is the generated design and contract, not an observed execution result. The paper's Table 2 metrics therefore remain reported reference values rather than independently verified outcomes.
Aligning and combining the ensemble
How can three different models contribute to one stock-price forecast without accidentally combining predictions for different dates? The practical rule is simple: first align every prediction by timestamp, then combine the aligned values. This matters especially for the Random Forest + XG-Boost + LSTM ensemble because the tabular models can predict every evaluation row, while the LSTM loses observations when it creates look-back windows.
The paper's proposed method combines random forest, XG-Boost, and LSTM predictions, but it does not specify the actual wiring. It calls the model “stacked” and also discusses assigning higher weights to models with lower loss. These descriptions do not identify one reproducible operation. A weighted average is a blending operation: fixed or externally supplied weights combine component outputs directly. Stacking usually means that a second-stage model learns how to combine component predictions. The generated code supports both alternatives and records which one was selected.
Prediction bundles preserve identity and time
A PredictionBundle connects a model name, a pandas index, and a prediction array. Its values may have shape (n,) or (n, 1), but they always represent one prediction per timestamp. The index must not contain duplicates, and the number of labels must equal the number of prediction rows. This is more than defensive programming: without the index, a numerically valid vector can still be paired with the wrong dates.
For example, the three ensemble components are named random_forest, xgboost, and lstm. Their predictions should represent the same target column and target scale. The LSTM bundle may have fewer timestamps because its first predictions require a complete look-back window.
The alignment module represents the result with AlignedPredictions. Its predictions field is a rank-2 matrix with shape (n_aligned_samples, n_components). For this ensemble, n_components is three, with columns ordered as random forest, XG-Boost, and LSTM. Its target field has shape (n_aligned_samples,), and its index contains the common timestamps in target order.
The core implementation performs an inner intersection while preserving the target's order:
def align_prediction_bundles(
bundles: Sequence[PredictionBundle], target: PredictionBundle
) -> AlignedPredictions:
"""Inner-align component predictions and targets by timestamp.
Args:
bundles: Component prediction bundles, typically RF, XG-Boost, and
LSTM predictions. Every bundle must have unique timestamps.
target: Target values indexed by their evaluation timestamps.
Returns:
An :class:`AlignedPredictions` record whose rows refer to exactly the
same timestamps across every component and the target.
"""
...The excerpt shows the public responsibility without reproducing the entire module. In the generated implementation, the function rejects empty component lists, repeated component names, duplicate indices, invalid numeric values, and an empty timestamp intersection. It obtains common timestamps from the target's original order rather than independently sorting each model's output. This preserves the temporal order used for evaluation.
Worked example: one shorter LSTM stream
Consider four evaluation dates: 2024-01-02, 2024-01-03, 2024-01-04, and 2024-01-05. Suppose random forest and XG-Boost produce four predictions, but the LSTM produces only three because the initial date was consumed by sequence construction. The following excerpt constructs that situation. It is an illustrative example of the public interfaces; it is not an execution result.
import numpy as np
import pandas as pd
from stock_forecasting.ensemble.alignment import align_prediction_bundles
from stock_forecasting.types import PredictionBundle
index = pd.to_datetime([
"2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"
])
lstm_index = index[1:]
target = PredictionBundle(
"close",
index,
np.array([10.0, 11.0, 12.0, 13.0]),
)
rf = PredictionBundle(
"random_forest",
index,
np.array([10.2, 10.8, 12.1, 12.7]),
)
xgb = PredictionBundle(
"xgboost",
index,
np.array([10.1, 11.1, 11.9, 13.1]),
)
lstm = PredictionBundle(
"lstm",
lstm_index,
np.array([10.9, 12.2, 12.9]),
)
aligned = align_prediction_bundles([rf, xgb, lstm], target)The common index is the final three dates. Conceptually, the resulting matrix is ordered by those dates and has three columns:
random_forest xgboost lstm
2024-01-03 10.8 11.1 10.9
2024-01-04 12.1 11.9 12.2
2024-01-05 12.7 13.1 12.9The important invariant is that each row refers to one date across all three columns. The first random-forest and XG-Boost predictions are discarded from the ensemble view because there is no corresponding LSTM prediction. The target is reduced to the same dates. This is preferable to padding the LSTM output or silently shifting its predictions.
Direct weighted blending
The generated weighted_average function implements one explicit interpretation of the paper's weight discussion. It requires a prediction matrix with shape (n_samples, 3) and a weight vector with shape (3,). The weights must be finite and nonnegative, and the implementation normalizes them to sum to one. The returned prediction vector has shape (n_samples,).
def weighted_average(predictions: np.ndarray, weights: np.ndarray) -> np.ndarray:
"""Combine aligned component predictions using normalized nonnegative weights.
The paper mentions weighting lower-loss models but does not define weights or
a fitting procedure. This implementation therefore makes the combination
explicit: the three supplied weights are required to be finite and
nonnegative, then normalized to sum to one. Columns must be ordered as
random forest, XG-Boost, and LSTM, and the return value has shape ``(n,)``.
"""
component_values = _validate_component_predictions(predictions)
component_weights = _as_finite_float_array(weights, "weights")
if component_weights.ndim != 1 or component_weights.shape[0] != _COMPONENT_COUNT:
raise ValueError("weights must have shape (3,)")
if np.any(component_weights < 0):
raise ValueError("weights must be nonnegative")
total = float(np.sum(component_weights))
if total <= 0.0:
raise ValueError("at least one weight must be positive")
normalized_weights = component_weights / total
return np.asarray(component_values @ normalized_weights, dtype=float)For equal weighting, a caller can provide (1/3, 1/3, 1/3). That choice is not recovered from the paper; it is an implementation decision. Likewise, weights derived from validation losses would require a documented rule for converting losses into weights. The code deliberately does not infer such a rule.
Learned linear stacking
The alternative is LinearStacker, a second-stage linear regressor. It receives the three component predictions as meta-features and learns coefficients and an intercept from training-only component outputs. Here, “meta-features” means features supplied to the combiner rather than the original stock columns.
The fitting function makes the data boundary explicit:
def fit_linear_stacker(
train_predictions: np.ndarray,
y_train: np.ndarray,
) -> LinearStacker:
"""Fit a training-only ordinary-least-squares linear stacker.
The paper calls the ensemble stacked but does not specify a meta-learner.
Ordinary least squares with an intercept is an explicit implementation
choice. ``train_predictions`` must contain exactly the three aligned
component predictions, and ``y_train`` must contain the corresponding
training targets; test targets must not be passed here.
"""
...The generated implementation validates the three-column input, checks that y_train has a matching number of rows, adds an intercept column, and solves the linear least-squares problem. Its predict method accepts another (n_samples, 3) matrix and returns one value per row. The important leakage constraint is not the particular linear model; it is that the stacker's fitting targets come only from permitted training data.
A rigorous stack often uses out-of-fold component predictions when fitting the meta-model. Otherwise, a stacker may learn from component predictions generated on the same rows used to fit those components, which can make the component outputs look unrealistically accurate. The paper does not specify out-of-fold generation, a validation arrangement, or even the meta-learner type. The generated framework therefore exposes ordinary least-squares stacking as a documented implementation choice rather than presenting it as the paper's recovered procedure.
Orchestrating the three components
StackedRFXGBoostLSTM owns one random-forest regressor, one XG-Boost regressor, and one LSTM regressor. Its tabular inputs have rank 2, while its LSTM inputs have rank 3. During fitting, the caller must supply pre-aligned training rows to all three components. The class then creates a three-column training prediction matrix. If the configured combiner is linear_stacking, it fits the meta-model on that matrix and the training targets.
During prediction, the class generates component outputs, associates the shorter LSTM output with the final evaluation timestamps, and calls align_prediction_bundles. The final values are then produced either by weighted_average or by the fitted LinearStacker. The resulting PredictionBundle is named rf_xgboost_lstm_ensemble and carries the aligned timestamps.
A configuration makes the ambiguity visible at the call site:
from stock_forecasting.config import EnsembleConfig
from stock_forecasting.ensemble.stacked import build_ensemble
ensemble_config = EnsembleConfig(
combiner="weighted_average",
weights=(1 / 3, 1 / 3, 1 / 3),
meta_features="predictions",
)
ensemble = build_ensemble(ensemble_config)Switching to combiner="linear_stacking" changes the operation from direct blending to a learned second stage. The combiner_metadata property records the selected combiner, weights, meta-feature description, component order, and the fact that the paper's wiring was not specified. This provenance is essential: a future reader can distinguish a paper fact from a reproduction decision.
The generated implementation has not been executed, and neither its alignment behavior nor its numerical output has been independently verified under the run policy. The intended contracts are nevertheless explicit: common timestamps, matching target semantics and scale, finite predictions, three fixed component columns, and no held-out test targets used to fit a learned combiner.
RMSE, R2, and auditable reporting
How do we know whether a stock-price prediction is useful? Compare predictions with the correct held-out targets, using the same timestamps and the same value scale. The generated metrics layer implements this final step through evaluate_regression_predictions, while build_metric_report adds stock and model identity for repeatable reporting.
This section concerns regression evaluation. Let y_true denote the observed target values from the held-out test period, and let y_pred denote the corresponding model predictions. Both are numeric arrays with shape (n_test,) or (n_test, 1). “Held-out” means that these observations were not used to fit the model or select its settings.
RMSE, or root mean square error, summarizes prediction error in the units of the target. If the target is an original stock price, RMSE is expressed in price units. If the target remains standardized, RMSE is expressed in standardized units instead. The implementation does not silently convert between these scales.
R2, or R-squared, is the coefficient of determination. It compares the model's residual error with the variation around a constant prediction based on the target mean. R2 can be negative when predictions are worse than that constant baseline; it is therefore incorrect to assume that every valid R2 must lie between zero and one.
Alignment is part of the metric contract
A numerically valid prediction is not necessarily a valid evaluation. The prediction must refer to the same observations as the target, in the same order. The metric function checks numeric dtype, finiteness, supported shapes, equal lengths, and optional index length and uniqueness. A pandas index can document timestamp alignment, although the caller remains responsible for constructing the two arrays from matching observations.
The core implementation normalizes one-dimensional and single-column inputs before calling standard regression metrics:
def _validate_metric_inputs(
y_true: np.ndarray, y_pred: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Validate target/prediction shape, finiteness, and observation count."""
true_values = _normalise_regression_values(y_true, "y_true")
predicted_values = _normalise_regression_values(y_pred, "y_pred")
if true_values.shape != predicted_values.shape:
raise ValueError(
"y_true and y_pred must have the same number of aligned observations; "
f"got {true_values.shape[0]} and {predicted_values.shape[0]}"
)
return true_values, predicted_valuesThe private helper _validate_metric_inputs converts accepted target shapes to compatible vectors and rejects mismatched lengths. It does not inspect the semantic content of timestamps, so the surrounding pipeline must preserve timestamp order when producing y_true and y_pred.
Computing and labeling the metrics
The public rmse function returns a finite, nonnegative scalar. The public r2_score function requires at least two observations because the standard coefficient of determination is undefined for a one-observation target. evaluate_regression_predictions performs the common checks and returns a dictionary containing rmse and r2.
def evaluate_regression_predictions(
y_true: np.ndarray,
y_pred: np.ndarray,
index: pd.Index | None = None,
scale: str = "original",
) -> dict[str, float]:
"""Return RMSE and R2 after checking held-out prediction alignment."""
if not isinstance(scale, str) or not scale.strip():
raise ValueError("scale must be a nonempty string")
true_values, predicted_values = _validate_metric_inputs(y_true, y_pred)
_validate_index(index, true_values.shape[0])
# Reuse the public metric functions after the common alignment checks.
metrics: dict[str, float] = {
"rmse": rmse(true_values, predicted_values),
"r2": r2_score(true_values, predicted_values),
}
if not all(np.isfinite(value) for value in metrics.values()):
raise ValueError("regression metrics must be finite")
return metricsThe scale argument is a reporting label, not a transformation instruction. Passing scale="original" means that the caller has already supplied original-scale values. Passing scale="standardized" records a different convention, but the function does not inverse-transform the arrays. If a scaler was used during preprocessing, the caller must apply the documented inverse target transformation before requesting original-price metrics.
Worked example: two aligned prediction bundles
A PredictionBundle associates a model name, a pandas index, and one prediction per index value. The following example creates a small target bundle and two model outputs. It is an interface example only; it was not executed in this pipeline and does not represent a paper result.
import numpy as np
import pandas as pd
from stock_forecasting.metrics.regression import (
evaluate_regression_predictions,
)
from stock_forecasting.types import PredictionBundle
index = pd.Index(
pd.to_datetime(["2023-01-03", "2023-01-04", "2023-01-05"]),
name="Date",
)
target = PredictionBundle(
"observed",
index,
np.array([10.0, 11.0, 12.0]),
)
svr_prediction = PredictionBundle(
"svr",
index,
np.array([10.2, 10.8, 12.1]),
)
ensemble_prediction = PredictionBundle(
"ensemble",
index,
np.array([10.1, 11.1, 11.9]),
)
svr_metrics = evaluate_regression_predictions(
target.values,
svr_prediction.values,
index=target.index,
scale="original",
)
ensemble_metrics = evaluate_regression_predictions(
target.values,
ensemble_prediction.values,
index=target.index,
scale="original",
)Here, both prediction arrays have three values and use exactly the target's index. The scale="original" label states that these illustrative values are being interpreted in the target's original scale. No numerical output is asserted because execution was disabled.
Building auditable records
A dictionary of metrics is useful for one call, but a multi-stock comparison also needs to identify the security and model. build_metric_report accepts a mapping from model names to PredictionBundle objects and aligns each prediction to the target index in target order. It returns immutable MetricRecord objects containing stock, model, rmse, r2, and scale.
def build_metric_report(
stock: str,
prediction_map: Mapping[str, PredictionBundle],
target: PredictionBundle,
scale: str,
) -> list[MetricRecord]:
"""Evaluate every named prediction bundle against one aligned target.
The caller supplies the reporting scale explicitly, for example ``"original"``
or ``"standardized"``. This function does not perform inverse scaling. It
also does not infer a train/test split or configuration provenance because
those fields are not part of :class:`MetricRecord`; callers should preserve
the corresponding experiment manifest separately.
"""The alignment step uses the intersection of target and prediction timestamps. This is important when, for example, an LSTM has fewer evaluable rows because its look-back windows omit initial observations. An empty timestamp intersection is treated as an error rather than producing misleading metrics.
A report can then be serialized with save_metric_report to either CSV or JSON. Records are sorted deterministically by stock, model, and scale. The serializer intentionally does not invent split, feature, target, or preprocessing information; those details belong in the experiment manifest.
Interpreting the paper's reported values
The paper's Table 2 reports RMSE and R2 for TAINIWALCHM and AGROPHOS across SVR, MLPR, KNN, LSTM, random forest, XG-Boost, and the ensemble. Those values are reference claims from the paper, not outputs independently reproduced by this generated implementation. The paper reports the ensemble as having the strongest comparison values, but the exact data preparation, target scale, and ensemble wiring are not fully specified.
In particular, the reported random-forest RMSE values are much larger than the other listed RMSE values while the corresponding R2 values are high. That combination can occur when target variance or scale differs, but the supplied evidence does not establish the cause. The implementation therefore preserves the reported values as references and does not correct, reinterpret, or claim to explain them.
The meaningful comparison rule is consistent evaluation: every model for a stock should use the same held-out period, aligned target timestamps, and explicitly recorded scale. A model's RMSE and R2 should not be compared across differently transformed targets without qualification. No API call, model training, metric calculation, test run, or independent verification was performed for this article section.
End-to-end offline workflow
How can you understand the complete reproduction workflow without immediately depending on live Yahoo Finance data? Start with a deterministic synthetic table, pass it through the same public pipeline used for a local snapshot, and inspect the resulting configuration and provenance. This demonstrates the plumbing without suggesting that synthetic data reproduces the paper's market-data experiment.
The paper reports Yahoo Finance data for TAINIWALCHM and AGROPHOS, but it does not provide exact ticker identifiers, a data snapshot, all preprocessing choices, or the full ensemble wiring. Consequently, a successful local run is illustrative unless those missing details are recovered. The generated code is designed to make that limitation visible.
One orchestration object for one experiment
The end-to-end entry point is run_experiment in src/stock_forecasting/experiments/pipeline.py. An ExperimentConfig groups the data-source description, preprocessing policy, training settings, ensemble choice, model configurations, tuning description, and package metadata. An ExperimentResult then carries the prepared data, prediction bundles, metric records, and an ExperimentManifest.
This separation distinguishes the paper's reported facts from implementation decisions. For example, the Table 1 configuration motivates the chronological 80/20 split, MSE, Adam, batch size 32, and a maximum of 50 LSTM epochs. In contrast, the feature set, target column, look-back window, scaler, random seed, selected tree parameters, and combiner remain explicit choices because the paper does not specify them.
The central orchestration function is deliberately small because each stage belongs to another module:
def run_experiment(
frame: pd.DataFrame,
stock_name: str,
configs: ExperimentConfig,
) -> ExperimentResult:
"""Prepare, fit, evaluate, and document one stock experiment.
The supplied frame is assumed to be a local snapshot. This function does
not make network requests. Preprocessing and model fitting are delegated to
their owning modules; the runner's held-out predictions are aligned by the
reporting layer before RMSE and R2 are computed. If optional TensorFlow or
XG-Boost dependencies are unavailable, the owning model adapter's explicit
import error is allowed to propagate, preserving the dependency boundary.
No numerical reproduction of Table 2 is implied: the paper omits several
choices required for an exact run, and this function performs no comparison
against the paper's reported values.
"""
_validate_frame(frame, stock_name)
if not isinstance(configs, ExperimentConfig):
raise TypeError("configs must be an ExperimentConfig")
prepared = prepare_stock_data(frame, configs.preprocessing)
fitted = fit_models_and_generate_predictions(
prepared,
configs.model_configs,
configs.training,
)
reporting_predictions = _convert_to_reporting_scale(fitted, prepared)
metrics = build_metric_report(
stock_name,
reporting_predictions.predictions,
reporting_predictions.target,
"original",
)
manifest = _build_manifest(stock_name, configs, reporting_predictions)
return ExperimentResult(
stock=stock_name,
prepared=prepared,
predictions=reporting_predictions,
metrics=metrics,
manifest=manifest,
)Notice the order: the supplied table is validated, preprocessing is performed, models generate held-out predictions, predictions are converted to the configured reporting scale, metrics are built, and the manifest records the experiment. The function does not download data and does not compare results with Table 2. ExperimentResult is therefore an audit container, not evidence that the paper's reported numbers were reproduced.
Worked example: deterministic synthetic input
The generated make_synthetic_stock_data function creates a chronological OHLCV-shaped DataFrame using a local NumPy random generator. Its columns are Open, High, Low, Close, Adj Close, and Volume. The dates, stochastic process, starting price, and seed are implementation choices; they are not properties of the paper's Yahoo Finance datasets.
The offline demonstration calls the generator as follows:
def run_demo() -> None:
"""Generate synthetic data, run the configured experiment, and print results."""
# The synthetic generator is the offline replacement for the paper's
# Yahoo Finance acquisition step; no network request is made here.
frame = make_synthetic_stock_data(n_rows=320, seed=7)
configs = _demo_configuration()
result = run_experiment(frame, stock_name="SYNTHETIC_DEMO", configs=configs)
_print_result(result)This function illustrates the complete data flow: acquisition is replaced by a deterministic local fixture, _demo_configuration() makes the missing modeling choices explicit, and run_experiment handles preparation, fitting, ensemble construction, and reporting. The output is labeled illustrative in the script. It cannot reproduce Yahoo Finance results or the paper's Table 2 values.
The demonstration configuration chooses Open, High, Low, and Volume as features, Close as the target, a 20-observation look-back, a one-step horizon, standard scaling, and equal-weight prediction blending. These choices are useful for showing the interfaces, but they must not be described as recovered experimental settings. The Table 1 80/20 split and the stated LSTM training settings are the more direct paper-aligned choices; the generic pipeline's 75/25 recommendation remains a documented conflict.
A conceptual invocation from the repository root is:
python scripts/run_offline_demo.pyThis command is an example of intended usage, not a command executed in this pipeline. The full comparison requires the relevant installed modeling dependencies, including TensorFlow and XG-Boost. Synthetic data generation itself uses NumPy and pandas, but that does not mean the complete experiment can run without the optional model packages.
The command-line interface and explicit choices
build_argument_parser in src/stock_forecasting/cli.py exposes both synthetic and CSV pathways. The mutually exclusive source options are visible in this excerpt:
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument(
"--csv",
type=Path,
help="Path to a local stock CSV snapshot.",
)
source.add_argument(
"--synthetic",
action="store_true",
help="Use deterministic synthetic OHLCV-like data for an offline demo.",
)The CLI also exposes the unresolved choices rather than concealing them. Its defaults select Close as the target, use Open, High, Low, and Volume as features, choose an 80% training fraction, use a 20-row look-back, a one-step horizon, and standard scaling. The parser help explicitly identifies the look-back and scaler as choices absent from the paper. The ensemble option accepts weighted_average or linear_stacking, because the paper calls the model stacked while also discussing higher weights for lower-loss models without defining the actual operation.
For a local CSV snapshot, an intended command could be written as:
python -m stock_forecasting.cli \
--csv data/stock_snapshot.csv \
--date-column Date \
--ticker EXPLICIT_TICKER \
--features Open,High,Low,Volume \
--target Close \
--train-fraction 0.8 \
--lookback 20 \
--horizon 1 \
--scaler standard \
--combiner weighted_average \
--weights 0.3333333333,0.3333333333,0.3333333333The exact ticker is intentionally supplied by the user. The paper's names and date ranges are not enough to guarantee a valid Yahoo Finance identifier. Likewise, Close is a configuration choice here, not a paper-established target. Before using real data, revisit the target, feature columns, endpoint dates, interval, scaling policy, sequence alignment, model settings, and ensemble combiner.
Loading a local snapshot
load_stock_csv is an offline implementation addition. It does not claim that the paper used CSV files; it provides a way to work from a saved snapshot rather than relying on a network call. The loader parses the requested date column, rejects malformed or duplicate timestamps, and returns a deterministically sorted DatetimeIndex:
parsed_dates = pd.to_datetime(frame[date_column], errors="coerce")
invalid_mask = parsed_dates.isna()
if bool(invalid_mask.any()):
invalid_rows = [int(position) for position in invalid_mask[invalid_mask].index[:5]]
suffix = "" if int(invalid_mask.sum()) <= 5 else " ..."
raise ValueError(
f"Date column {date_column!r} contains "
f"{int(invalid_mask.sum())} unparseable value(s); "
f"example row labels: {invalid_rows}{suffix}"
)
result = frame.drop(columns=[date_column]).copy()
result.index = pd.DatetimeIndex(parsed_dates, name=date_column)
result = result.sort_index(kind="mergesort")
if result.index.has_duplicates:
duplicate_count = int(result.index.duplicated(keep=False).sum())
raise ValueError(
f"CSV snapshot contains {duplicate_count} row(s) with duplicate "
f"timestamps; resolve duplicates before loading: {csv_path}"
)The important contract is provenance and ordering. The date column becomes the index, and duplicate timestamps are an explicit failure rather than being silently aggregated or discarded. Feature selection, null handling, zero-value policy, scaling, and target construction remain responsibilities of the preprocessing pipeline.
What the output means
The demonstration prints metric records and a manifest. A metric record identifies the stock, model, RMSE, R2, and reporting scale. The manifest records the choices needed to interpret those values, including the data-source label, feature and target configuration, split, sequence settings, model configurations, tuning description, and combiner.
This is particularly important for the paper's Table 2. The paper reports RMSE and R2 for TAINIWALCHM and AGROPHOS, including an ensemble result, but the supplied implementation has not been executed and the missing experimental details prevent an exact reconstruction. A locally produced metric is therefore an output of the chosen local configuration, not an independently verified Table 2 result. Even using real Yahoo Finance data would not remove the uncertainty about the original ticker symbols, selected features, scaler, look-back, validation protocol, selected hyperparameters, or ensemble wiring.
The generated CLI includes a warning in its JSON payload that says the outputs do not independently reproduce Table 2. That warning is part of the implementation's audit posture: it prevents an illustrative offline workflow from being mistaken for a verified paper replication.
Practical dependency boundary
The CSV and synthetic loaders are designed for offline data handling. The Yahoo Finance client is isolated in its own module, so the CLI does not make an implicit network request. However, the full model comparison includes a TensorFlow/Keras LSTM and an optional XG-Boost adapter. Those dependencies may be unavailable in an offline environment. A missing dependency should produce an explicit adapter-level error rather than silently replacing the model with another algorithm.
No API call, training run, test run, syntax check, or semantic code verification occurred for this tutorial section. The workflow and commands describe the generated interfaces and intended behavior. They should be treated as a reproducible starting point whose outputs require local execution, matching data, and careful interpretation before they can be compared with the paper's reported references.
Fidelity ledger, limitations, and responsible interpretation
How can a codebase be reproducible without being an exact reproduction? The distinction is evidence. A paper fact is a setting or result explicitly supplied by the paper. An implementation decision is a choice made because the paper leaves a detail open. An ambiguity is an unresolved detail that prevents a unique reconstruction. A reported reference is a value transcribed from the paper, not a result independently produced by this project.
For this paper, the Section 4 comparison is the main reproduction target: SVR, MLPR, KNN, random forest, XG-Boost, LSTM, and the Random Forest + XG-Boost + LSTM ensemble. The generated framework preserves that scope, but it does not claim exact numerical reproduction. The paper does not provide source code, a data snapshot, complete preprocessing instructions, or the ensemble's actual wiring.
What is known and what remains a decision
Paper facts. Table 1 specifies an 80/20 training/test split for the proposed ensemble, MSE and Adam for the neural component, a maximum of 50 epochs, batch size 32, two LSTM layers, dropout 0.2, and a dense layer with 25 units. It also lists candidate values for random-forest and XG-Boost parameters. The paper reports Yahoo Finance data for TAINIWALCHM and AGROPHOS and evaluates models using RMSE and R2.
Derived implementation rationale. The framework uses a chronological split because later observations should represent the held-out period. It fits learned preprocessing and any learned combiner using training data only. It aligns predictions by timestamp because LSTM sequence construction can remove initial rows. These are safeguards and design explanations, not evidence that the original experiment used precisely the same procedures.
Implementation decisions and ambiguities. The exact Yahoo Finance ticker identifiers, selected feature columns, target column, scaler, look-back window, forecast horizon, validation procedure, random seed, selected hyperparameters, and ensemble combiner are not specified. The code therefore exposes them through configuration and records them in ExperimentManifest instead of hiding them in defaults. The generic pipeline also mentions a 75/25 split, which conflicts with Table 1's more specific 80/20 setting; this implementation treats 80/20 as the primary reproduction choice and records the conflict.
The paper's dataset description contains another fidelity issue: AGROPHOS is associated with the 2018–2023 period, but one sentence incorrectly uses the TAINIWALCHM name for that description. The framework does not silently repair the source text. The stock label and the API ticker remain explicit fields so a user can document the chosen interpretation.
The manifest as an audit boundary
The manifest separates a stock label from the data-source ticker and stores preprocessing, training, tuning, and ensemble choices. A compact excerpt from src/stock_forecasting/experiments/schema.py shows the fields that prevent an apparently reproducible run from becoming an undocumented guess:
@dataclass(frozen=True, slots=True)
class ExperimentManifest:
"""Audit record for one stock-specific reproduction configuration.
The manifest deliberately stores unresolved paper details as explicit fields or
notes rather than hiding them in defaults. ``model_configurations`` records
selected settings when supplied; an empty mapping means that no selected model
settings were provided by the caller or recovered from the paper.
"""
paper_id: str
stock: str
data_source: Mapping[str, Any]
feature_columns: tuple[str, ...]
target_column: str
train_fraction: float
split_policy: str
scaler_name: str | None
lookback: int
horizon: int
seed: int | None
tuning_method: str
validation_fraction: float | None
model_configurations: Mapping[str, Any]
combiner: str
ensemble_weights: tuple[float, ...] | None
meta_features: strThe important point is not the dataclass syntax itself. feature_columns and target_column make the supervised task explicit; lookback and horizon identify the temporal construction; scaler_name and train_fraction expose preprocessing choices; and tuning_method, model_configurations, combiner, and ensemble_weights record choices the paper does not recover. validate_manifest is responsible for rejecting incomplete or invalid records, such as an empty target name, an invalid fraction, duplicate features, or an ensemble weight vector with the wrong length.
A compact fidelity ledger for one hypothetical run could therefore read as follows:
Paper fact: Table 1 specifies an 80/20 split and a 25-unit dense layer.
Implementation decision: use Close as target and Open, High, Low, Volume as features.
Unresolved ambiguity: the paper does not define whether the ensemble is weighted blending or learned stacking.
Reported reference: Table 2 lists the ensemble's AGROPHOS RMSE as 1.2658 and R2 as 0.9897; this value is not independently verified here.This record keeps a chosen target separate from a paper-specified training ratio, and it keeps a transcribed number separate from an observed output of the generated code.
Damaged and incomplete equations
The equation registry in src/stock_forecasting/equations.py is deliberately conservative. For KNN, eq_2 has an empty canonical LaTeX string because the extracted Euclidean-distance expression is damaged. The code may implement a vector distance for the KNN adapter, but that is an implementation decision and must not be presented as reconstructed paper notation. The registry preserves the empty value:
"eq_2": CanonicalEquation(
equation_id="eq_2",
latex="",
meaning="Euclidean-distance expression for KNN.",
),The CanonicalEquation record stores an identifier, the supplied LaTeX text, and its meaning. get_equation retrieves that immutable record without normalizing OCR artifacts. This makes eq_2 traceable while respecting the rule that missing mathematical text must not be invented.
The paper's LSTM gate records are intact enough to preserve as supplied, but they do not define a complete LSTM recurrence. They omit the candidate-cell computation and the cell-state and hidden-state updates. The framework therefore uses a Keras LSTM for the complete trainable model and treats the following records as conceptual gate mappings only.
The input-gate record is introduced here to identify the information-control operation printed by the paper:
Here, iga is the input-gate activation, σ is an elementwise sigmoid, Wip is the printed input-gate weight matrix, ht−1 is the previous hidden state, Xc is the current input as printed, and bi is the input-gate bias. The paper does not specify matrix dimensions, so these symbols represent compatible vectors, tensors, and parameters rather than a recoverable complete shape contract. In code, input_gate_eq_8 names the mapping to the framework LSTM concept; it does not claim to hand-implement the missing cell equations.
The forget-gate record describes the corresponding gate that controls retained information:
Here, fga is the forget-gate activation, σ is the sigmoid function, Wf g is the printed forget-gate weight matrix, ht−1 is the previous hidden state, Xc is the current input as printed, and b f is the forget-gate bias. The extraction has damaged subscript and multiplication formatting, and the equation still omits the subsequent cell-state update. The generated forget_gate_eq_9 function therefore provides traceability to the paper record rather than a claim of full mathematical reconstruction.
The output-gate record describes the gate controlling the exposed hidden representation:
Here, Opg is the output-gate activation, σ is sigmoid, Wop is the output-gate weight matrix, ht−1 is the previous hidden state, Xc is the current input as printed, and bo is the output-gate bias. As with the other two records, dimensions and the remaining recurrent updates are not supplied. The generated output_gate_eq_10 function names this correspondence while the actual model is delegated to the Keras implementation.
The important fidelity rule is to preserve these three strings exactly, including their extracted notation, while explaining that they are incomplete. Extending them into a standard textbook LSTM formula would change the supplied record and would falsely imply that the paper specified the missing steps.
Ensemble terminology and reporting limits
The paper calls the Random Forest + XG-Boost + LSTM model “stacked,” but it also discusses assigning greater weight to lower-loss models. These are not automatically the same operation. Weighted averaging, also called a form of blending, combines component predictions using explicit weights. Stacking normally trains a second-stage model, or meta-model, on component predictions. The generated framework exposes both alternatives and records the selected combiner; neither is presented as the recovered paper implementation.
The same caution applies to the reported metrics. RMSE is an error measure in the target's units, while R2 is the coefficient of determination. They are meaningful only when predictions and targets refer to the same timestamps and scale. Table 2 values are reported references. In particular, the unusually large random-forest RMSE values are preserved rather than corrected: the supplied material does not establish whether they reflect scaling, evaluation inconsistency, or a reporting issue.
The paper's conclusions do not establish a universal forecasting method. Stock relationships can change across securities, market regimes, date ranges, data revisions, and preprocessing choices. Accordingly, a result from one period should not be treated as a general guarantee. The paper also frames AI predictions as support for investment decisions rather than their sole determinant; that limitation remains relevant even when a model produces favorable retrospective metrics.
What would improve fidelity
Exact reproduction would require the original Yahoo Finance ticker identifiers and a fixed data snapshot, including interval and endpoint behavior. It would also require the selected feature and target columns, null and zero handling, scaler and inverse-transform policy, sequence look-back and horizon, validation and tuning procedure, selected model parameters, random seeds, package versions, complete LSTM settings, and the actual ensemble wiring. Source code or an original experiment manifest would resolve much of this uncertainty.
Under the current run policy, no API call, training run, test execution, syntax check, or semantic code verification was performed. The generated tests and ledger are planned safeguards, not evidence that the implementation matches Table 2. The responsible interpretation is therefore precise: the framework reproduces the paper's documented model families and stated configuration elements while making its unresolved choices explicit; it does not establish independently verified financial-forecasting results.
Static and semantic verification plan
How can we tell whether this reproduction framework respects the paper's assumptions without pretending that the experiment has already run? Separate four activities: static verification, semantic verification, testing, and execution. Static verification inspects structure such as syntax, imports, names, and configuration values. Semantic verification reviews whether the code's behavior is consistent with the intended method, including time alignment and leakage boundaries. Tests encode repeatable checks for those contracts. Execution actually runs the package, trains models, calls data sources, or produces metrics.
For this project, execution was disabled by the run policy. Local static verification and code semantic verification were also skipped. Therefore, the checks described below are planned invariants and test specifications, not observed passes. In particular, no test, syntax check, API call, model-training run, or numerical reproduction of Table 2 was performed.
What the planned checks protect
The generated tests focus on six categories:
Rank and shape. Tabular features must remain rank 2, sequence inputs rank 3, and predictions must have one value per aligned observation.
Time order. Cleaning must produce ordered, unique timestamps; chronological splits must not overlap; and test observations must follow training observations.
Sequence alignment. Every look-back window must contain observations before its target timestamp. Initial rows lost during window construction must not be silently paired with another model's predictions.
Leakage control. Learned preprocessing state, candidate selection, and any ensemble meta-model must use training data only.
Finite outputs and metric alignment. Predictions and targets must have equal lengths, matching timestamps, and finite numeric values before RMSE or R2 is computed.
Paper traceability. Canonical equation text and Table 1 candidate grids must be preserved exactly, while damaged equations must remain unreconstructed.
These are implementation constraints derived from the method cards, not additional results reported by the paper.
Data and temporal invariants
The data tests in tests/test_data_invariants.py express the earliest contracts. For example, the cleaning test checks ordering, uniqueness, duplicate handling, and the retained final duplicate value:
def test_clean_table_orders_and_deduplicates() -> None:
"""Cleaning sorts timestamps and keeps the final occurrence of duplicates."""
timestamps = pd.to_datetime(
["2024-01-03", "2024-01-01", "2024-01-02", "2024-01-02"]
)
raw = pd.DataFrame(
{
"Open": [3.0, 1.0, 2.0, 20.0],
"Close": [3.5, 1.5, 2.5, 25.0],
"Volume": [30.0, 10.0, 20.0, 200.0],
},
index=pd.DatetimeIndex(timestamps, name="Date"),
)
cleaned = clean_stock_table(raw, remove_zero_values=False)
assert cleaned.index.is_monotonic_increasing
assert cleaned.index.is_unique
assert list(cleaned.index) == list(pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]))
assert cleaned.loc[pd.Timestamp("2024-01-02"), "Close"] == pytest.approx(25.0)
assert cleaned.shape == (3, 3)The important point is not the particular fixture dates. It is that clean_stock_table must make duplicate and ordering behavior explicit. The paper says to remove redundancies and nulls, but it does not prescribe this exact duplicate policy; the test documents the generated implementation's chosen contract.
The chronological split test checks the primary 80/20 reproduction decision:
def test_chronological_split_has_no_overlap() -> None:
"""The split preserves order and places every test observation after training."""
index = pd.date_range("2024-01-01", periods=10, freq="D", name="Date")
X = pd.DataFrame({"feature": np.arange(10, dtype=float)}, index=index)
y = pd.Series(np.arange(10, dtype=float) + 100.0, index=index, name="target")
X_train, X_test, y_train, y_test = chronological_split(X, y, train_fraction=0.8)
assert len(X_train) == len(y_train) == 8
assert len(X_test) == len(y_test) == 2
assert X_train.index.equals(y_train.index)
assert X_test.index.equals(y_test.index)
assert set(X_train.index).isdisjoint(set(X_test.index))
assert X_train.index[-1] < X_test.index[0]This protects both shape and chronology. It does not establish that 80/20 is the only correct split: the paper's generic pipeline mentions 75/25, while Table 1 specifies 80/20 for the proposed ensemble. The test simply records which policy the reproduction framework has chosen as its primary target.
Sequence and scaling invariants
An LSTM consumes a rank-3 array with shape (batch, sequence_length, n_features). The sequence test checks that a look-back window precedes its future target and that the target index is retained:
def test_sequences_preserve_target_alignment() -> None:
"""Each sequence contains only observations before its future target."""
index = pd.date_range("2024-01-01", periods=8, freq="D", name="Date")
X = np.arange(8, dtype=float).reshape(-1, 1)
y = (100.0 + np.arange(8, dtype=float)).reshape(-1, 1)
result = make_sequences(X, y, index, lookback=3, horizon=2)
# The first target is position lookback + horizon - 1 = 4.
assert result.X_train.ndim == 3
assert result.X_train.shape[1:] == (3, 1)
assert result.X_train[0, :, 0].tolist() == [0.0, 1.0, 2.0]
assert result.y_train[0] == pytest.approx(104.0)
assert result.train_index[0] == index[4]The paper does not supply the look-back or horizon. Those are implementation decisions, so the test verifies the chosen sequence-construction semantics rather than a paper-specified numerical setting. This invariant is essential to preprocess_time_series and to stacked_rf_xgboost_lstm: tree models may produce predictions for rows that the LSTM cannot represent until enough history exists.
Scaling has a separate leakage constraint. A scaler is a learned transformation, so it must be fitted on training rows only. The planned test checks that the stored means come from X_train and y_train, not from the deliberately extreme test values:
def test_scaler_fit_scope_is_training_only() -> None:
"""The fitted preprocessing state records only training rows and excludes test values."""
X_train = np.array([[1.0], [3.0], [5.0]], dtype=float)
y_train = np.array([10.0, 20.0, 30.0], dtype=float)
X_test = np.array([[1000.0]], dtype=float)
y_test = np.array([10000.0], dtype=float)
state = fit_scalers(X_train, y_train, scaler_name="standard")
assert state.fit_rows == X_train.shape[0]
assert state.feature_scaler.mean_.tolist() == pytest.approx([3.0])
assert state.target_scaler.mean_.tolist() == pytest.approx([20.0])The paper does not specify the scaler type, whether targets are scaled, or whether reported metrics use original prices. The invariant therefore concerns fit scope, not a particular scaler choice. Any inverse transformation used before reporting must preserve target alignment and scale consistency.
Equation traceability without reconstruction
Equation traceability has a special rule in this project: the supplied canonical equation records are authoritative. Equation eq_2, the KNN distance expression, has empty canonical LaTeX because the source extraction is damaged. The implementation may provide the named euclidean_distance_eq_2 adapter as an explicit vector-distance decision, but it must not present reconstructed LaTeX as if it came from the paper.
The same rule applies to the LSTM gate records. The supplied canonical text for eq_8 describes the input-gate activation as printed:
The input-gate record is preserved exactly below.
Here iga is the input-gate activation, σ is the elementwise sigmoid function, Wip is the printed input-gate weight matrix, ht−1 is the previous hidden state, Xc is the current input notation used in the extracted equation, and bi is the input-gate bias. These are vector or tensor quantities in an implementation, although the paper does not provide dimensions. The generated input_gate_eq_8 function is a traceable helper name, not evidence that the incomplete printed expression defines the entire recurrent cell.
The forget-gate record is preserved exactly as eq_9.
Here fga is the forget-gate activation, σ is again applied elementwise, Wf g is the printed forget-gate weight matrix, ht−1 is the previous hidden state, Xc is the current input, and b f is the forget-gate bias. The corresponding code symbol is forget_gate_eq_9.
The output-gate record is preserved exactly as eq_10.
Here Opg is the output-gate activation, Wop is the output-gate weight matrix, ht−1 is the previous hidden state, Xc is the current input, and bo is the output-gate bias. The generated mapping is named output_gate_eq_10.
The three records describe individual gate activations only. They omit the candidate-cell computation, cell-state update, and hidden-state update. The framework-level Keras LSTM therefore supplies the complete recurrent behavior; the code does not hand-reconstruct unsupported equations. The planned traceability test checks exact strings and function names:
def test_damaged_equations_remain_empty() -> None:
"""Ensure damaged source records are not silently reconstructed in the registry."""
for equation_id in ("eq_2", "eq_4", "eq_6"):
assert get_equation(equation_id).latex == ""
def test_lstm_methods_reference_eq_8_eq_9_eq_10() -> None:
"""Check that the three conceptual gate adapters are exposed as named callables."""
gate_functions = {
"eq_8": input_gate_eq_8,
"eq_9": forget_gate_eq_9,
"eq_10": output_gate_eq_10,
}
for equation_id, function in gate_functions.items():
assert callable(function)
assert equation_id in function.__name__These checks are planned evidence-preservation checks. They do not numerically validate an LSTM and do not turn incomplete equations into a complete mathematical specification.
Model-contract and paper-fidelity checks
The model-contract tests target the interfaces used by fit_models_and_generate_predictions. The LSTM contract is rank 3 in and one regression output per sequence:
def test_lstm_uses_rank_three_inputs_and_single_output() -> None:
"""The configured neural model has (batch, sequence, feature) input and (batch, 1) output."""
pytest.importorskip("tensorflow")
model = build_lstm_regressor(
input_shape=(6, 4),
units=5,
dropout=0.2,
dense_units=25,
)
assert model.input_shape == (None, 6, 4)
assert model.output_shape == (None, 1)
assert sum(layer.__class__.__name__ == "LSTM" for layer in model.layers) == 2This checks the stated two-layer architecture, dropout setting, dense width, and shape contract when TensorFlow is available. It does not check training quality or assert that the unspecified recurrent-unit count is the paper's count. Similarly, test_paper_candidate_grids_match_table_1 is intended to compare the random-forest and XG-Boost candidate values with Table 1. Candidate values are not selected hyperparameters, and this check does not imply that the paper used the same search procedure as the generated code.
Ensemble alignment and leakage checks
The ensemble is the area where semantic review matters most. Matching array shapes alone cannot prove that random-forest, XG-Boost, and LSTM predictions refer to the same timestamps. The alignment test deliberately gives each component different order and coverage, then requires an intersection ordered by the target:
def test_component_predictions_align_by_timestamp() -> None:
"""Component predictions use the target order and only common timestamps."""
target_index = _index(0, 1, 2, 3)
target = PredictionBundle("target", target_index, np.array([10.0, 20.0, 30.0, 40.0]))
random_forest = PredictionBundle(
"random_forest",
_index(3, 1, 0),
np.array([39.0, 19.0, 9.0]),
)
xgboost = PredictionBundle(
"xgboost",
_index(2, 0, 3),
np.array([29.0, 11.0, 41.0]),
)
lstm = PredictionBundle("lstm", _index(0, 2, 3), np.array([10.5, 30.5, 40.5]))
aligned = align_prediction_bundles((random_forest, xgboost, lstm), target)
expected_index = _index(0, 3)
np.testing.assert_array_equal(aligned.index, expected_index)
np.testing.assert_allclose(
aligned.predictions,
np.array([[9.0, 11.0, 10.5], [39.0, 41.0, 40.5]]),
)The resulting component matrix has shape (n_aligned_samples, 3), with columns for random forest, XG-Boost, and LSTM. A second planned check verifies that a linear stacker is fit from component predictions and training targets only. This is especially important because the paper calls the ensemble “stacked” but also discusses higher weights for lower-loss models. Weighted averaging and learned stacking are different operations, and neither wiring is recovered from the paper.
Worked verification checklist
For one prepared dataset and one ensemble output, the intended review checklist is:
| Invariant | Planned question | Evidence location | |---|---|---| | Rank | Is tabular X rank 2, sequence X rank 3, and each prediction vector aligned? | tests/test_model_contracts.py | | Time order | Are timestamps unique, chronological, and non-overlapping across train and test? | tests/test_data_invariants.py | | Window safety | Does every LSTM window precede its target? | test_sequences_preserve_target_alignment | | Scaling leakage | Was preprocessing state fitted only on training rows? | test_scaler_fit_scope_is_training_only | | Equation fidelity | Are intact strings unchanged and eq_2 still empty? | tests/test_equation_traceability.py | | Table 1 fidelity | Do RF and XG-Boost candidate grids match the supplied values? | test_paper_candidate_grids_match_table_1 | | Ensemble alignment | Do all three components share the same target timestamps? | test_component_predictions_align_by_timestamp | | Meta-model leakage | Was a learned combiner fit without held-out test targets? | test_stacker_fit_excludes_test_targets | | Metric inputs | Do y_true and y_pred have equal aligned lengths? | test_metric_inputs_must_align |
This checklist labels intended obligations, not completed results. A future verification run could use it to review one synthetic or local-CSV experiment before considering a real Yahoo Finance reproduction.
Verification boundary and final caution
The planned tests are contract tests, not evidence that the paper's reported RMSE and R2 values have been reproduced. Synthetic data and local CSV data can exercise the pipeline, but they are not substitutes for the paper's unavailable Yahoo Finance snapshot and unspecified preprocessing and stacking decisions. Likewise, a successful model fit would not establish that Table 2 was independently verified.
Advanced detail. A learned stacker can introduce a second form of leakage if its training predictions were generated on the same rows used to fit the component models. A robust stacking design often uses out-of-fold component predictions, but the paper does not specify such a procedure. The generated framework therefore records the combiner choice and its training data boundary rather than claiming that the paper recovered this detail.
The honest conclusion is narrow: the repository defines useful invariants for preprocess_time_series, fit_models_and_generate_predictions, stacked_rf_xgboost_lstm, and evaluate_regression_predictions, but those invariants were not executed or independently reviewed in this pipeline. Treat them as a verification plan to apply before using any resulting metrics, especially for financial decisions.
Use the button or URL below to download the source code.


