Quant Trading: Feature-Wise Compositional RNNs & Grey Wolf Optimization for Stock Prediction (PyTorch Guide)
Building an end-to-end multi-stream LSTM, GRU, and SRU neural architecture with metaheuristic GWO hyperparameter optimization.
What This Article Builds
The paper proposes a feature-wise compositional recurrent neural network approach for multivariate stock-price forecasting. The five OHLCV inputs are modeled separately with stacked LSTM, GRU, or SRU recurrent layers, regularized with dropout, fused by concatenation, optionally processed by additional recurrent layers and dense layers, and optimized using either Random Search (RS) or Grey Wolf Optimizer (GWO). The study evaluates 54 configurations on daily Hang Seng Index data from Yahoo Finance. The reported best result is an LSTM-GWO configuration, followed by GRU-GWO and SRU-GWO configurations.
Implementation Assumptions
The five input channels are ordered Open, High, Low, Close, Volume.
The 20-day and 40-day values are treated as input window lengths, not forecast horizons.
Because the paper does not identify the target or horizon, the implementation exposes targetcolumn and forecasthorizon as configuration choices; the tutorial must label these as implementation decisions.
Scaling defaults to training-only fitting over [0, 0.95] to reduce leakage, while documenting that the paper does not specify the fitting partition.
The paper's opaque configuration labels such as LSTM-GWO (1-1-0-1) are retained as benchmark labels and are not decoded into architecture parameters.
The training loss, gradient optimizer, search spaces, GWO budget, and metric formulas are configurable implementation decisions rather than asserted paper facts.
SRU is represented behind a local interface because the paper does not specify an SRU variant or API.
Yahoo Finance acquisition is optional for offline reproducibility; local OHLCV files and deterministic synthetic data are supported.
No reported benchmark is treated as verified reproduction output.
No canonical equations were supplied, so no equation-level LaTeX or equation_id mapping is asserted.
Verification is limited to planned static and semantic checks; execution, generated tests, and local verification are disabled by policy.
Scope, evidence status, and reproduction target
What should happen when five market measurements describe the same trading day? The paper’s central idea is to avoid sending all of them immediately into one undifferentiated recurrent pathway. Instead, Open, High, Low, Close, and Volume each receive their own temporal pathway. The resulting representations are then joined before the final forecast. A useful intuition is to treat the five pathways as specialists: each studies one signal over time, and a prediction head combines their reports.
Here, OHLCV means the ordered features Open, High, Low, Close, and Volume. The generated model expects tensors in batch × time × feature layout. For a 20-day input window, for example, one batch has shape batch × 20 × 5; for the alternative window, the shape is batch × 40 × 5. The final dimension must retain the canonical OHLCV order.
What the paper establishes
The supplied paper context supports a feature-wise compositional recurrent method. Each OHLCV stream is processed independently by a stacked LSTM, GRU, or SRU family. Dropout is used for regularization, the five representations are fused through concatenation, and optional recurrent and dense layers produce the stock-price prediction. The paper also compares two outer hyperparameter strategies:
Random Search (RS) samples candidate configurations stochastically.
Grey Wolf Optimizer (GWO) maintains a population of candidates and updates them according to their relative objective values.
The paper reports 54 evaluated configurations and identifies an LSTM-GWO configuration as its best reported result. It also reports GRU-GWO and SRU-GWO benchmark configurations. These are useful comparison targets, but they do not fully describe how to construct the corresponding models.
The phrase “univariate encoding” in the extracted paper should be read carefully. The implementation target is not one single-variable forecasting model. It is feature-wise encoding: five separate one-channel sequences are modeled and then fused into a multivariate representation.
What remains underdetermined
An exact reproduction cannot be recovered from the supplied context alone. Several decisions needed by executable code are absent or ambiguous:
The Yahoo Finance symbol, date range, adjustment policy, and missing-row treatment are not uniquely specified.
The target column and forecast horizon are not identified. The paper names stock-price prediction, but does not establish that the target is
Closeor that the horizon is one day.The recurrent widths, exact layer counts, hidden-state extraction rule, dense widths, activations, and SRU implementation are missing.
The training loss, gradient optimizer, learning-rate schedule, stopping rule, and checkpoint policy are unspecified.
RS bounds, sampling distributions, trial counts, and validation details are absent.
GWO population size, iteration budget, bounds, initialization, discrete-parameter encoding, and update specification are absent.
Metric formulas and conventions, including percentage-error zero handling and evaluation scale, are not supplied.
The labels LSTM-GWO (1-1-0-1), GRU-GWO (2-1-1-1), and SRU-GWO (2-2-0-0) therefore remain opaque benchmark labels. They must not be decoded into presumed layer counts, dropout switches, or dense-layer structures.
How the generated package responds
The generated package treats the paper as a method specification plus a set of reported targets, not as a complete executable recipe. DataConfig exposes the target, horizon, window length, split, and scaling range. ModelConfig exposes the recurrent family and architecture choices. TrainingConfig, SearchConfig, and EvaluationConfig make training, search, and metric conventions explicit. validate_reproduction_config provides a common boundary for checking these records.
The model assembly is represented by CompositionalRNN and build_compositional_model. Their intended responsibilities are to create five independent recurrent encoders, concatenate their representations, apply the configured fusion treatment, and produce the configured output dimension. RS and GWO remain separate outer-search interfaces, but both are intended to evaluate candidates through a shared training and validation objective.
This separation is important. A derived implementation decision—such as using Close as the target, a one-step horizon, or a particular loss—can be changed when better evidence becomes available without changing the paper’s feature-wise architecture or the public configuration interfaces. It also prevents a convenient default from being mistaken for a fact reported by the paper.
Worked example: an explicit tutorial configuration
For a small paper-oriented setup, choose window_length=20, target_column='Close', and forecast_horizon=1 in DataConfig. These values are tutorial choices: the paper supports 20-day input windows, but it does not specify Close as the target or a one-step forecast horizon. A corresponding ModelConfig can select LSTM and an explicitly documented number of stream layers and units, while a TrainingConfig records the chosen loss, optimizer, batch size, epochs, and seed.
The resulting input contract is batch × 20 × 5, and the configured output dimension determines the prediction shape. That shape contract describes the scaffold; it does not demonstrate that the configuration reproduces the paper’s reported LSTM-GWO result.
Evidence status
This section and the generated package distinguish three kinds of statements:
Paper facts: feature-wise OHLCV processing, recurrent-family comparison, concatenation fusion, RS and GWO comparison, and the reported benchmark labels and values.
Derived explanations: the “five specialists” intuition and the interpretation of separate pathways as feature-wise rather than single-variable modeling.
Implementation decisions: target and horizon defaults, exact architecture settings, preprocessing policies, training choices, search budgets, and metric conventions.
The run policy disabled execution, local static verification, semantic code verification, generated test execution, tutorial verification, and final quality review. Consequently, this package should be described as a configurable reproduction scaffold, not as a verified implementation or a successful reproduction of the published results.
Data contract: HSI OHLCV acquisition and provenance
How can a forecasting model learn from market data if the source rows, feature order, and cleaning decisions are unclear? The data contract answers that question before any recurrent layer is built. It specifies which five measurements enter the model, preserves their chronological order, and records choices that the paper does not disclose.
What the paper specifies—and what it does not
The paper identifies daily Hang Seng Index history obtained through Yahoo Finance/YFinance and names the raw Open, High, Low, Close, and Volume fields as its OHLCV inputs. In the generated package, the canonical order is fixed as Open, High, Low, Close, Volume. A raw table therefore has shape num_days × 5: each row represents one observation and each column represents one input channel.
The paper does not provide a unique Yahoo Finance ticker, date range, download options, adjusted-price setting, missing-value policy, or duplicate-row policy. These are not minor details. Different tickers or date ranges change the observations, while adjusted and unadjusted prices can produce different price histories. The generated implementation consequently makes these choices explicit and stores them in AcquisitionMetadata rather than presenting a particular choice as recovered paper methodology.
The reproduction scope also stays deliberately narrow. It retains the five raw OHLCV fields and does not add technical indicators or other engineered predictors. Technical indicators are mentioned by the paper as possible future work, not as part of the evaluated method.
Canonical columns and provenance
CANONICAL_OHLCV_COLUMNS is the package-level contract used by acquisition and model-facing code:
# Canonical feature order required by the paper-oriented data and model APIs.
OHLCVColumns: TypeAlias = tuple[str, str, str, str, str]
CANONICAL_OHLCV_COLUMNS: OHLCVColumns = (
"Open",
"High",
"Low",
"Close",
"Volume",
)This excerpt is copied from src/compositional_rnn_stock/types.py. OHLCVColumns describes a five-name tuple, while CANONICAL_OHLCV_COLUMNS supplies the concrete order. Preserving this order matters because later model code interprets the final input axis as five channels. Reordering columns—for example, placing Volume before Close—would change the meaning of the model input without changing its shape.
The acquisition record captures the choices surrounding those columns:
@dataclass(frozen=True)
class AcquisitionMetadata:
"""Provenance and cleaning-policy record for an OHLCV acquisition.
The paper does not specify the Yahoo Finance symbol, date range, adjustment
mode, missing-value policy, or duplicate-row policy. These fields preserve
the choices made by a local reproduction instead of treating them as paper
facts.
"""
source: str
symbol: str | None
start: str | None
end: str | None
adjusted: bool | None
frequency: str
requested_columns: OHLCVColumns
missing_value_policy: str
duplicate_policy: str
ordering_policy: strThis is an implementation record, not an additional claim about the paper. For a network acquisition, source, symbol, start, end, and adjusted identify the request. requested_columns records the five retained fields, and the remaining policy fields describe how the returned table was accepted. For an offline archive, some fields such as symbol may be None, but the artifact still records that the source was a local CSV.
Two acquisition paths
The generated adapter supports a network path through load_hsi_ohlcv_from_yfinance and an offline path through load_ohlcv_csv. The dispatcher requires exactly one source, preventing an ambiguous call that supplies both a symbol and a local file:
def acquire_hsi_ohlcv_data(
*,
symbol: str | None = None,
local_path: Path | None = None,
start: str | None = None,
end: str | None = None,
adjusted: bool = False,
) -> tuple[pd.DataFrame, AcquisitionMetadata]:
"""Dispatch to an explicit local-file or Yahoo Finance acquisition mode."""
if (symbol is None) == (local_path is None):
raise ValueError("provide exactly one of symbol or local_path")
if local_path is not None:
return load_ohlcv_csv(local_path, CANONICAL_OHLCV_COLUMNS)
return load_hsi_ohlcv_from_yfinance(symbol=symbol, start=start, end=end, adjusted=adjusted)This excerpt is copied from src/compositional_rnn_stock/data/acquisition.py. The function returns a pandas table plus its provenance record. In local mode, it delegates to load_ohlcv_csv; in Yahoo Finance mode, it delegates to load_hsi_ohlcv_from_yfinance. The optional yfinance import is isolated inside the network function, so an offline workflow does not need that package merely to load an archived file.
For a local archive, a typical call is:
frame, metadata = load_ohlcv_csv(Path("data/hsi_ohlcv.csv"), CANONICAL_OHLCV_COLUMNS)The generated CSV loader looks for a conventional Date, date, Timestamp, or timestamp column and uses it as the index when present. It then selects and orders only the canonical OHLCV fields. An archive should therefore preserve its date column whenever the identity of trading days matters. If no recognized date column exists, the loader retains the file's row index; that is a documented limitation of the local input rather than evidence that dates were absent from the original paper dataset.
For Yahoo Finance, the caller must supply the symbol and may supply explicit date bounds and an adjustment choice. The paper does not identify these values, so a tutorial command should not silently substitute a supposedly canonical ticker or time range. The command-line entry point instead requires explicit source and target-related settings, while the acquisition function records the selected source parameters.
Validation and cleaning
Acquisition first validates the structural contract. validate_ohlcv_table requires a nonempty pandas table, all five columns, numeric finite values, an increasing index, and no duplicate index values. It does not infer missing observations or create rows. The relevant responsibility is summarized by its public interface:
def validate_ohlcv_table(frame: pd.DataFrame) -> None:
"""Validate a chronological table containing exactly the five OHLCV fields.
Validation deliberately rejects missing values and duplicates rather than
fabricating observations. The paper leaves those policies unspecified, so
callers must clean or otherwise resolve such records before acquisition
results are accepted.
"""The implementation's cleaning layer makes the missing and duplicate policies explicit:
cleaned = clean_ohlcv(frame, missing_policy="drop", duplicate_policy="reject")This call is the focused usage pattern exposed by src/compositional_rnn_stock/data/cleaning.py. The `
Scaling, target definition, and sliding windows
How does an ordered table of daily market observations become training data without allowing future information to leak backward? The preprocessing pipeline answers this in three stages: scale the five OHLCV channels, construct overlapping time windows, and split the resulting supervised samples chronologically.
The paper states that the features are normalized to the interval [0, 0.95] and that the model uses 20-day and 40-day temporal windows. It does not supply a canonical scaling equation, identify the target column, or specify the forecast horizon. The generated implementation therefore makes those choices explicit instead of presenting them as recovered paper facts.
Scaling five channels independently
FeaturewiseMinMaxScaler treats Open, High, Low, Close, and Volume as five separate numeric channels. It stores one minimum and maximum for each channel, so the much larger numerical scale of Volume does not determine the scaling of price features. Its final feature axis must have length five, while arbitrary leading dimensions are allowed. Thus it can process both a raw table shaped num_days × 5 and model inputs shaped samples × window_length × 5.
The conventional feature-wise min-max mapping used by the generated scaler is an implementation decision because no canonical equation was supplied. The implementation also chooses to fit statistics on training observations only. This is a leakage-prevention policy: test observations are transformed using training metadata, rather than helping define that metadata. The paper states the target interval but does not state which partition fits the normalization statistics, so this choice must remain visible in provenance.
A focused part of the generated scaler shows the fitting contract:
scaler = FeaturewiseMinMaxScaler(feature_range=feature_range)
scaler.fit(training_values)
return scalerHere, training_values is expected to contain only the earlier chronological observations selected by the caller. The scaler records feature_min_, feature_max_, and feature-specific scale metadata. Constant training features are mapped to the lower bound and restored to their fitted constant during inverse transformation, avoiding division by zero. Values outside the fitted extrema are not clipped by transform; the generated code documents this as an intentional choice so extrapolation remains visible.
The fit, transform, and inverse_transform methods preserve the input shape. That matters later when a prediction is converted back from normalized units to original price units. If evaluation is performed on original prices, the target column must be identified so that the corresponding feature scaling metadata can be applied.
Target and horizon are explicit choices
The paper describes stock-price prediction and lists OHLCV predictors, but it does not say whether the target is Close, adjusted Close, another price field, or a multivariate output. It also does not specify how far into the future the target lies. The generated windowing API therefore requires both target_index and forecast_horizon.
window_length describes how many past observations enter one model input. For paper-oriented runs, it is treated as either 20 or 40 trading days. forecast_horizon is different: it describes the offset from the end of the input window to the target observation. The 20-day and 40-day values should not be interpreted as prediction horizons merely because they are temporal quantities.
For a start position s, the generated implementation uses the input rows from s through the end of the selected window, then takes the target at the configured future position. With window_length=3 and forecast_horizon=1, the first input contains rows 0, 1, and 2, and its target is row 3. A horizon of 2 would instead select row 4 for that same first input.
The key implementation fragment is copied from make_sliding_windows:
sample_count = day_count - window_length - forecast_horizon + 1
if sample_count <= 0:
raise ValueError(
"insufficient observations for the requested window and horizon: "
f"num_days={day_count}, window_length={window_length}, "
f"forecast_horizon={forecast_horizon}"
)
inputs = np.empty((sample_count, window_length, _FEATURE_COUNT), dtype=np.float64)
targets = np.empty(sample_count, dtype=np.float64)
target_timestamps = None if timestamp_array is None else np.empty(sample_count, dtype=timestamp_array.dtype)
for start in range(sample_count):
end = start + window_length
target_position = end + forecast_horizon - 1
inputs[start] = array[start:end]
targets[start] = array[target_position, target_index]
if target_timestamps is not None:
target_timestamps[start] = timestamp_array[target_position]The resulting WindowedDataset records inputs with shape samples × window_length × 5 and scalar targets with shape samples. It also records the selected target column, horizon, and optional target timestamps. The five channels remain in canonical Open, High, Low, Close, Volume order; window construction does not reorder or mix them.
The following focused example is the same alignment pattern used by the generated contract tests. It uses synthetic arrays only and does not represent Hang Seng Index data:
dataset = make_sliding_windows(
values,
target_index=3,
window_length=3,
forecast_horizon=2,
timestamps=timestamps,
)
assert dataset.inputs.shape == (4, 3, 5)
assert dataset.targets.shape == (4,)
np.testing.assert_array_equal(dataset.inputs[0], values[0:3])
np.testing.assert_array_equal(dataset.targets, values[4:8, 3])This example uses target index 3, which corresponds to Close in the canonical order. That index is an example configuration choice, not evidence that the paper definitively forecasts Close.
Chronological splitting
After windows and targets have been aligned, chronological_split divides complete supervised samples without shuffling. Its boundary is the floor of sample_count * train_fraction. The first partition contains earlier target timestamps, and the second contains later ones. The function rejects a split that would leave either partition empty.
The generated test expresses the central ordering invariant:
train, test = chronological_split(dataset, train_fraction=0.6)
expected_train_count = int(dataset.sample_count * 0.6)
assert train.sample_count == expected_train_count
assert test.sample_count == dataset.sample_count - expected_train_count
assert train.sample_count > 0
assert test.sample_count > 0
assert train.timestamps is not None
assert test.timestamps is not None
assert int(train.timestamps[-1]) < int(test.timestamps[0])These are contract tests supplied in the generated repository, but they were not executed under the authoritative run policy. They describe intended behavior rather than reporting a completed verification.
The paper reports an 80%/20% chronological partition and gives sample counts of 4,678 training samples and 1,170 test samples. Those counts are useful reproduction targets, but they do not uniquely reveal the raw date range, the number of overlapping windows, or whether the 20-day and 40-day datasets were constructed separately.
How the prepared-data pipeline orders its work
prepare_data records the selected target, horizon, window length, split ratio, scaling range, and cleaning policies. Its relevant flow is:
observation_split = _observation_split_index(
raw_values.shape[0], data_config.split_ratio
)
scaler = fit_scaler_on_training_observations(
raw_values=raw_values,
split_index=observation_split,
feature_range=data_config.scaling_range,
)
scaled_values = scaler.transform(raw_values)
dataset = make_sliding_windows(
values=scaled_values,
target_index=_target_index(data_config.target_column),
window_length=data_config.window_length,
forecast_horizon=data_config.forecast_horizon,
timestamps=timestamps,
)
train_dataset, test_dataset = chronological_split(
dataset,
train_fraction=data_config.split_ratio,
)Notice the distinction between the observation boundary used to fit the scaler and the later sample split. The generated pipeline first determines an observation-level boundary, fits the scaler on the leading observations, transforms the ordered series, constructs windows, and then partitions the completed samples. This is the package's documented leakage-avoidance design, not a uniquely specified procedure from the paper. Because overlapping windows can span an observation boundary, a stronger reproduction should confirm the intended split order from the original experiment details.
The pipeline also records scaler_fit_policy as training_observations_only, along with the target column, horizon, observation counts, window sample counts, and scaling range. These records make it possible to replace an assumption later without changing the public interfaces.
Returning to original units
Normalized inputs are useful for model training, but reported price errors may need to be calculated in original units. FeaturewiseMinMaxScaler.inverse_transform restores all five channels, while a target-specific evaluation adapter can select the configured target feature. This distinction is important because the paper does not specify whether its metrics were calculated on normalized values or inverse-transformed prices.
The generated offline demonstration follows the same policy on synthetic data: it fits the scaler on an earlier chronological portion, transforms the complete ordered sequence, and constructs 20-day windows. The demonstration is useful for checking shape contracts conceptually, but it is not HSI data and cannot establish the paper's reported performance.
In summary, the preprocessing contract is precise about shapes and ordering but deliberately honest about unresolved semantics. The implementation preserves five feature channels, treats 20 and 40 as input lengths, aligns each window with an explicitly configured future target, and prevents test observations from fitting the scaler. The target column, forecast horizon, split order, and normalization convention remain choices that must be documented for any reproduction run.
The feature-wise compositional recurrent model
How can five related market signals contribute to one forecast without being mixed too early? The paper’s answer is feature-wise composition: Open, High, Low, Close, and Volume each travel through an independent recurrent pathway. Their learned summaries are then placed side by side and passed to a prediction head. This is not five separate forecasting models. The final forecast is multivariate because the five representations are fused before prediction.
The paper supports this overall structure, but it does not uniquely specify the recurrent widths, exact layer counts, representation extraction rule, dense-layer widths, or activation functions. The generated package therefore implements these details through ModelConfig. The code is a configurable reproduction scaffold, not a verified reconstruction of every hidden architecture choice.
Tensor flow through five independent streams
The public model input, x, has shape batch × time_steps × 5. The final dimension follows the canonical order Open, High, Low, Close, Volume. A 20-day run therefore uses batch × 20 × 5; a 40-day run uses batch × 40 × 5.
FeatureWiseEncoder splits that final dimension into exactly five tensors. Each slice has shape batch × time_steps × 1, so one recurrent stack receives one feature channel rather than the complete OHLCV vector. The five encoders are separate module instances, which means their parameters are not shared.
The following excerpt shows the stream-level contract. Notice that the recurrent stack is configured with return_sequences=False; this local choice selects the final time-step representation for fusion. The paper does not say whether the final hidden state, a complete sequence, or another aggregation was used.
class FeatureStreamEncoder(nn.Module):
"""Encode one OHLCV channel with its own recurrent stack.
The input contract is ``(batch, time_steps, 1)`` and the output contract is
``(batch, hidden_size)``. A separate instance is created for every OHLCV
channel, so parameters are not shared between feature streams.
"""
def __init__(
self,
recurrent_family: RecurrentFamily | str,
hidden_size: int,
layers: int,
dropout_rate: float = 0.0,
feature_name: str | None = None,
) -> None:
super().__init__()
if isinstance(hidden_size, bool) or not isinstance(hidden_size, int) or hidden_size <= 0:
raise ValueError(f"hidden_size must be a positive integer; received {hidden_size!r}")
if isinstance(layers, bool) or not isinstance(layers, int) or layers <= 0:
raise ValueError(f"layers must be a positive integer; received {layers!r}")
if feature_name is not None and feature_name not in CANONICAL_OHLCV_COLUMNS:
raise ValueError(
f"feature_name must be one of {CANONICAL_OHLCV_COLUMNS!r}; received {feature_name!r}"
)
self.feature_name = feature_name
self.input_size = 1
self.hidden_size = hidden_size
self.layer_count = layers
self.recurrent_family = RecurrentFamily.coerce(recurrent_family)
self.recurrent = build_recurrent_stack(
family=self.recurrent_family,
input_size=self.input_size,
hidden_size=hidden_size,
layers=layers,
return_sequences=False,
)
self.dropout = DualDropout(stream_rate=dropout_rate, fusion_rate=0.0)The important invariant is input_size=1: every stream receives one feature channel. After recurrent processing, each stream produces a fixed-width tensor with shape batch × hidden_size. Stream dropout is then applied. Dropout is a training-time regularizer: it masks values while the model is training and is disabled when the module is in evaluation mode.
FeatureWiseEncoder.forward preserves the channel order with a one-element split along the final axis:
# Each slice retains its one-channel axis: (batch, time_steps, 1).
feature_slices = torch.split(x, split_size_or_sections=1, dim=-1)
if len(feature_slices) != 5:
raise RuntimeError(f"expected five feature slices, received {len(feature_slices)}")
encoded_streams = tuple(
encoder(feature_slice)
for encoder, feature_slice in zip(self.encoders, feature_slices)
)This code does not perform early averaging, summation, or mixing. The five encoded outputs remain separate until the fusion module receives them.
One interface for LSTM, GRU, and SRU
A recurrent layer processes a sequence while maintaining a learned state that carries information across time steps. LSTM and GRU are the two standard recurrent families exposed by the generated wrapper. The paper also evaluates SRU, but the supplied paper context does not identify an SRU variant or Python API.
RecurrentFamily provides a common selector, and build_recurrent_stack returns a RecurrentStack with the same input and output contract for each family. For a stream, the input is batch × time_steps × 1. With final-time-step reduction, the output is batch × hidden_size.
class RecurrentFamily(str, Enum):
"""Supported recurrent-family selectors.
``SRU`` is included because it is one of the families evaluated by the
paper. The paper does not identify an SRU variant or Python API, so the
local adapter below is an explicit implementation choice rather than a
claim of exact SRU reproduction.
"""
LSTM = "LSTM"
GRU = "GRU"
SRU = "SRU"For LSTM and GRU, the wrapper constructs batch-first PyTorch stacks. The local SRUAdapter is different: it is a dependency-free compatibility adapter supplied by the generated implementation. Its documentation explicitly says that it is not a canonical SRU implementation. Consequently, selecting SRU makes the interface available for experimentation, but it must not be described as proof of paper-equivalent SRU behavior.
The wrapper also validates rank, feature width, positive time steps, and floating-point input. Invalid inputs fail explicitly rather than being silently reshaped. This matters because changing batch × time_steps × features to another ordering would change the meaning of the recurrent computation.
Dropout and concatenation fusion
The paper describes dual dropout: one stage after feature-specific recurrent processing and another stage around feature fusion. The first stage is clear enough to place after each stream encoder. The second stage is not: “around fusion” does not establish whether it is before concatenation, after concatenation, or both. DualDropout and ConcatenationFusion expose this as fusion_placement.
Concatenation means placing the representations end to end along the final dimension. If each of the five streams has width hidden_size, the fused representation has width 5 × hidden_size. The operation preserves the batch dimension and does not combine channels by summation.
# The paper's stated fusion operation is concatenation along the
# representation dimension; stream representations are never summed.
fused = torch.cat(processed_streams, dim=-1)
if self.fusion_placement in {"after_concatenation", "both"}:
fused = self.fusion_dropout.apply_fusion(fused)
return fusedvalidate_stream_shapes requires exactly five floating-point tensors, matching leading dimensions, a positive representation width, the same dtype, and the same device. A mismatch such as one stream having a different batch size is rejected before concatenation. The default generated placement applies fusion dropout after concatenation, but that default is an implementation decision rather than a recovered paper setting.
Optional post-fusion processing and prediction
After fusion, PostFusionHead can either send the fused vector directly to dense layers or apply additional recurrent processing first. The paper allows post-fusion recurrent layers but does not specify how a fused vector should be treated as a sequence. The generated head makes one explicit choice: it adds a single time step, giving a tensor shaped batch × 1 × fused_features, and then uses the final representation from that one-step recurrent stack.
Dense layers then produce the configured target output. The generated implementation uses ReLU between configurable dense layers, followed by a final linear layer. Both the widths and the activation choice are local implementation decisions. The output shape is batch × output_dimension, where output_dimension must agree with the separately configured target definition.
Assembling the complete model
CompositionalRNN connects the stages in order: validate the input, encode five streams, concatenate them, apply the optional post-fusion head, and validate the prediction shape. Its central forward path is deliberately explicit:
# Feature-wise recurrent encoding: five tensors of shape
# (batch, stream_units), preserving the canonical OHLCV order.
encoded_streams = self.feature_encoder(x)
if len(encoded_streams) != self.input_feature_count:
raise RuntimeError(
f"expected {self.input_feature_count} encoded streams, "
f"received {len(encoded_streams)}"
)
# Paper architecture stage: fuse representations by concatenation.
fused = self.fusion(encoded_streams)
expected_fused_shape = (x.shape[0], self.fused_representation_size)
if tuple(fused.shape) != expected_fused_shape:
raise RuntimeError(
"fusion returned an unexpected shape: "
f"received {tuple(fused.shape)}, expected {expected_fused_shape}"
)
# Optional post-fusion recurrence and dense prediction are delegated to
# the head; its recurrent representation is a configurable choice.
prediction = self.post_fusion_head(fused)The model boundary requires exactly five channels and a positive batch and time dimension. It returns batch × output_dimension; an unexpected output shape raises an error. These checks enforce the paper-supported architecture without pretending that unspecified widths or layer counts have been recovered.
Focused configuration example
The following excerpt is adapted exactly from the generated model-contract test. It demonstrates a small local configuration with one recurrent layer, eight stream units, one dense layer, and one output. Those values are demonstration settings, not the paper’s reported best architecture. The 20 in the input shape represents the paper-oriented window choice, while the target semantics remain configured elsewhere.
def _make_model_config(
family: RecurrentFamily | str = RecurrentFamily.LSTM,
*,
stream_dropout: float = 0.0,
fusion_dropout: float = 0.0,
) -> ModelConfig:
"""Build a small deterministic configuration for contract tests.
These tests verify structural invariants of the implementation rather than
the paper's unresolved architecture details or opaque benchmark labels.
"""
return ModelConfig(
recurrent_family=family,
stream_layers=1,
stream_units=8,
stream_dropout=stream_dropout,
fusion_dropout=fusion_dropout,
fusion_placement="after_concatenation",
post_fusion_layers=0,
post_fusion_units=8,
dense_layers=(8,),
output_dimension=1,
)A caller would construct the model and prepare an input with shape batch × 20 × 5 as follows:
model = build_compositional_model(_make_model_config())
inputs = _make_inputs(batch_size=4, time_steps=20)
predictions = model(inputs)The intended contract is predictions with shape 4 × 1 for this configuration. This excerpt was not executed under the run policy, so it demonstrates the interface and expected shapes only; it does not establish runtime correctness or paper-level reproduction.
The opaque labels in the reported results—LSTM-GWO (1-1-0-1), GRU-GWO (2-1-1-1), and SRU-GWO (2-2-0-0)—are deliberately not translated into ModelConfig fields. The supplied paper context does not define what those four positions mean. Keeping them as benchmark labels prevents an attractive but unsupported architecture claim.
The generated test plan reinforces structural invariants such as five independent streams, additive fusion width, common family dispatch, and training-only dropout. Those tests were not executed: local static verification, semantic verification, and code execution were disabled or skipped by policy.
Training one candidate without hiding missing specifications
What does it mean to train one candidate model in this reproduction? The candidate receives batches of chronological windows, produces predictions, computes a selected scalar loss, and updates its trainable parameters with gradients. After each epoch, a separate validation pass records loss with dropout disabled and without changing the parameters. This inner training loop is distinct from Random Search and Grey Wolf Optimizer (GWO), which choose different candidate configurations outside the loop.
Paper facts and implementation choices
The paper says that recurrent models are trained and compared using validation performance, and it names learning rate, batch size, and training epochs among the optimized hyperparameters. However, the supplied paper context does not specify the training loss, base gradient optimizer, initialization, learning-rate schedule, random seed, batch-shuffling policy, early-stopping rule, or checkpoint-selection rule. It also does not clearly define how the reported 80%/20% partition relates to the validation loss discussed in the method.
The generated code makes these gaps visible. TrainingConfig supplies the local choices, while ForecastLoss supports explicit mse, mae, and huber objectives. These are available implementation options, not claims about which loss reproduced the paper. Likewise, make_optimizer supports explicit gradient optimizers for a candidate; this inner optimizer should not be confused with RS or GWO, which operate at the outer configuration-search level.
The generated pipeline uses the prepared test partition as the validation loader for a one-candidate run. Its source documentation explicitly calls this an implementation decision because the paper does not define a separate validation construction. Consequently, this scaffold should not be described as recovering the paper’s exact validation protocol.
Validating predictions and targets
A forecasting loss is meaningful only when each prediction is paired with its corresponding target. validate_loss_inputs requires PyTorch tensors with floating-point dtypes, identical shapes, at least one element, and finite values. A mismatch, empty batch, integer tensor, or non-finite value raises an error rather than allowing silent broadcasting or an invalid objective.
The public loss interface is ForecastLoss. Its output is a scalar tensor, so it can participate in backpropagation. The generated implementation uses mean reduction for each supported local objective. The following excerpt shows the explicit dispatch; it is copied from src/compositional_rnn_stock/losses.py.
if self.name == "mse":
loss = F.mse_loss(predictions, targets, reduction="mean")
elif self.name == "mae":
loss = F.l1_loss(predictions, targets, reduction="mean")
else:
loss = F.smooth_l1_loss(
predictions,
targets,
beta=self.huber_delta,
reduction="mean",
)The inputs have the model’s configured target shape. In the scalar-target pipeline, the model returns batch × 1, while window datasets expose targets as batch. The training loop performs only this unambiguous single-output reshape; other mismatches are rejected. That safeguard matters because accidental broadcasting could produce a finite-looking loss with incorrect semantics.
One epoch: update in training mode, evaluate in validation mode
During training, train_one_candidate puts the model in training mode, reads a batch shaped batch × time_steps × 5, computes predictions, aligns the targets, backpropagates the scalar loss, and calls the selected optimizer. The five-channel input contract is checked before the model is used. A training loader with no batches or non-finite values is rejected.
The core update sequence is shown below. This excerpt is copied from src/compositional_rnn_stock/training/loops.py.
for batch in train_loader:
inputs, targets = _prepare_batch(batch, device, "training")
optimizer.zero_grad(set_to_none=True)
predictions = model(inputs)
targets = _align_targets(predictions, targets)
loss = loss_fn(predictions, targets)
loss.backward()
optimizer.step()After the training batches, evaluate_loss switches the model to evaluation mode and wraps inference in torch.no_grad(). Evaluation mode is important for the compositional model because dropout must be disabled when validation predictions are measured. The function restores the model’s previous training state afterward, computes a value-weighted mean over the validation targets, and rejects an empty or non-finite result.
was_training = model.training
model.eval()
total_loss = 0.0
total_values = 0
try:
with torch.no_grad():
for batch in loader:
inputs, targets = _prepare_batch(batch, device, "validation")
predictions = model(inputs)
targets = _align_targets(predictions, targets)
loss = loss_fn(predictions, targets)
value_count = int(targets.numel())
total_loss += float(loss.detach().cpu().item()) * value_count
total_values += value_count
finally:
model.train(was_training)This separation is the main training invariant: validation loss is observed, not optimized directly. The validation loader is never passed to backward() or optimizer.step() by this workflow. The paper’s exact split and model-selection policy remain unresolved, so the generated code retains the final state after the configured epochs rather than silently inventing a best-checkpoint rule.
Optimizer and reproducibility settings
make_optimizer constructs the inner gradient optimizer from the configured name and learning rate. The generated implementation accepts adam, adamw, and sgd in this function. It validates that the learning rate is positive and finite, that the model has trainable parameters, and that the optimizer name is supported. The paper does not identify which of these, if any, was used.
if normalized == "adam":
return Adam(parameters, lr=learning_rate)
if normalized == "adamw":
return AdamW(parameters, lr=learning_rate)
return SGD(parameters, lr=learning_rate)set_global_seed seeds supported local Python, NumPy, and PyTorch generators. SeedContext additionally snapshots and restores available random states and can request deterministic PyTorch algorithms. These facilities improve the comparability of local experiments, but the paper does not report its seed or deterministic-backend settings. A seed therefore belongs in provenance, not in a claim that the paper used the same setting.
Histories and provenance records
Each completed epoch becomes an EpochRecord containing its epoch number, training loss, and validation loss. TrainingHistory preserves insertion order and rejects non-increasing epoch numbers. Its best_epoch helper can identify the earliest record with the smallest available validation loss, but the training loop does not automatically restore that epoch’s weights. Selecting and restoring a best checkpoint would be an additional explicit implementation decision.
The outer record is TrainingRun. It links the trained CompositionalRNN, its TrainingHistory, the ModelConfig, the TrainingConfig, and a prepared-data identifier. That identifier records acquisition and preprocessing metadata, window length, forecast horizon, target column, and train/test sample counts. This prevents a local loss curve from becoming detached from choices such as Close versus another target, a 20-day versus 40-day window, or a particular scaling policy.
Worked example: defining a local candidate run
The following configuration fragment is copied from scripts/train_model.py. It selects Close and a one-step horizon, but those are tutorial-level implementation choices because the paper does not identify the target column or forecast horizon.
data_config = DataConfig(
symbol=args.symbol,
local_path=args.local_path,
start=args.start,
end=args.end,
target_column=args.target_column,
forecast_horizon=args.forecast_horizon,
window_length=args.window_length,
split_ratio=args.split_ratio,
scaling_range=(0.0, 0.95),
)A focused call path inside train_one_candidate then creates the selected loss and optimizer and trains for the configured number of epochs. This excerpt is copied from src/compositional_rnn_stock/training/loops.py.
loss_fn = _loss_for_config(config)
optimizer = make_optimizer(model, config)
device = _model_device(model)
history = TrainingHistory()At the pipeline level, run_training validates the prepared data and configurations, builds chronological loaders, constructs the model, checks that loader inputs have shape batch × time_steps × 5, and delegates to train_one_candidate.
trained_model, history = train_one_candidate(
model=model,
train_loader=train_loader,
validation_loader=validation_loader,
config=training_config,
)This call defines one local experiment. It does not establish that the loss, optimizer, architecture settings, validation protocol, or final model state match the paper. It also does not execute here: the authoritative run policy disabled code execution and verification.
Why this boundary matters
The gradient loop answers, “How do we fit one selected candidate?” RS and GWO answer a different question, “Which candidate settings should we try next?” Keeping those responsibilities separate allows both search methods to use the same loss, loaders, validation objective, and provenance schema. It also makes missing paper specifications inspectable instead of burying them in defaults.
For this scaffold, the honest output of training is a locally configured model, an epoch-aligned history, and provenance describing the choices. No training result, convergence behavior, or successful reproduction of the paper’s reported benchmarks is claimed. Verification was skipped under policy, so the code excerpts describe the generated interfaces and intended control flow rather than a checked execution outcome.
Random Search and Grey Wolf Optimizer
How should a reproduction choose among many possible recurrent-network recipes? Random Search (RS) samples independent candidates, while Grey Wolf Optimizer (GWO) maintains a population of candidates and moves them using the best-ranked candidates as guides. In both cases, a candidate is more than a model family: it can include recurrent-layer count, recurrent units, dropout rates, learning rate, batch size, and training epochs.
The paper names both RS and GWO and reports that GWO performed better across the LSTM, GRU, and SRU families. However, it does not specify the search bounds, probability distributions, number of trials, wolf population, iteration count, initialization, update settings, invalid-candidate policy, or randomization controls. The generated package therefore treats these as explicit implementation configuration. No displayed setting in this section should be read as the paper's missing experimental setting.
One objective shared by both methods
The outer optimizer does not train a model directly. Instead, it proposes a typed CandidateConfig. The shared candidate objective builds the corresponding compositional model, creates or receives chronological training and validation loaders, trains the candidate, and records its best validation loss. Validation loss is used here as a local implementation choice because the paper discusses validation performance but does not define the loss or the exact selection rule.
CandidateResult stores the candidate configuration, objective, metrics, provenance, success status, and any failure information. SearchResult stores the selected result and the complete search history. This separation matters: a failed candidate remains visible in the history rather than being silently treated as the best candidate, and the held-out test set is not needed for hyperparameter selection.
The objective's public protocol is deliberately small. Its candidate has type CandidateConfig, its data bundle must provide training and validation loaders, and its seed is recorded for reproducibility. The generated implementation explicitly records that test data were not used for selection:
provenance: dict[str, Any] = {
"seed": seed,
"objective": "minimum_validation_loss",
"selection_basis": "lowest validation loss recorded during training",
"test_data_used_for_selection": False,
}This excerpt comes from src/compositional_rnn_stock/search/objectives.py. The objective returns a CandidateResult after train_one_candidate supplies a TrainingHistory; the history must contain a validation loss from which the lowest recorded value can be selected. The code does not claim that this objective is the paper's exact loss or checkpoint policy.
The same selection helper serves both optimizers. Its minimize argument makes the direction explicit, which is important because validation loss is normally minimized, whereas some metrics are maximized:
def select_best_candidate(
results: Sequence[CandidateResult],
minimize: bool,
) -> CandidateResult:
"""Select a successful candidate using an explicit objective direction."""
if not isinstance(minimize, bool):
raise TypeError(f"minimize must be boolean; received {type(minimize).__name__}")
if not isinstance(results, Sequence):
raise TypeError("results must be a sequence of CandidateResult instances")
validated: list[CandidateResult] = []
for index, result in enumerate(results):
if not isinstance(result, CandidateResult):
raise TypeError(
f"results[{index}] must be a CandidateResult; "
f"received {type(result).__name__}"
)
if result.success:
if result.objective is None:
raise ValueError(
f"successful results[{index}] must contain an objective"
)
validated.append(result)
if not validated:
failure_count = sum(1 for result in results if not result.success)
raise ValueError(
"cannot select a best candidate: no successful candidate results "
f"were available ({failure_count} recorded failures)"
)
if minimize:
return min(validated, key=lambda result: float(result.objective))
return max(validated, key=lambda result: float(result.objective))The function accepts a sequence of results, rejects malformed successful records, ignores unsuccessful records for selection, and raises an error when no successful candidate exists. That failure behavior is a derived implementation safeguard, not a reported paper procedure.
Typed search spaces
A search space must represent different kinds of values. Recurrent-layer counts, units, batch sizes, and epochs are integer-valued. Dropout rates and learning rates are continuous. A model family is categorical when one search spans LSTM, GRU, and SRU. ParameterDomain gives each parameter one of these meanings and validates its bounds or allowed values.
The following excerpt is copied from src/compositional_rnn_stock/search/space.py and shows the domain constructors exposed by the generated package:
@classmethod
def continuous(cls, lower: float, upper: float) -> "ParameterDomain":
return cls("continuous", lower=lower, upper=upper)
@classmethod
def integer(cls, lower: int, upper: int) -> "ParameterDomain":
return cls("integer", lower=lower, upper=upper)
@classmethod
def categorical(cls, values: Sequence[Any]) -> "ParameterDomain":
return cls("categorical", values=tuple(values))
@classmethod
def discrete(cls, values: Sequence[Any]) -> "ParameterDomain":
return cls("discrete", values=tuple(values))continuous and integer domains use inclusive lower and upper bounds. categorical and discrete domains hold explicit values. The distinction between categorical and discrete values is useful when documenting intent, although both are represented by positions when a vector-based optimizer needs numeric coordinates.
SearchSpace keeps the named domains in a stable order and exposes encoded bounds. CandidateConfig then groups the decoded values into ModelConfig, TrainingConfig, and any explicitly declared extra parameters. candidate_from_mapping validates a named mapping before constructing that typed record. The paper's list of optimized hyperparameters tells us what should be represented, but not the actual ranges; those ranges must be supplied by the caller.
Random Search: independent proposals
RS draws one value from every domain for each trial. The generated random_search function accepts a SearchSpace, a callable CandidateObjective, and a search configuration containing a seed, trial count, data bundle, and objective direction. Each trial receives a derived candidate seed, and each result is appended to the history, including failures.
The core loop is implemented as follows in src/compositional_rnn_stock/search/random_search.py:
history: list[CandidateResult] = []
for trial_index in range(trial_count):
candidate = sample_random_candidate(space, rng)
candidate_seed = seed + trial_index
try:
result = objective(candidate, data_bundle, candidate_seed)
if not isinstance(result, CandidateResult):
raise TypeError(
"objective must return CandidateResult; "
f"received {type(result).__name__}"
)
except Exception as exc:
result = CandidateResult(
configuration=candidate,
objective=None,
metrics={},
provenance={
"method": "random_search",
"trial_index": trial_index,
"seed": candidate_seed,
"failure_type": type(exc).__name__,
},
success=False,
error=f"{type(exc).__name__}: {exc}",
)
else:
result.provenance.update(
{
"method": "random_search",
"trial_index": trial_index,
"sampler_seed": seed,
"candidate_seed": candidate_seed,
}
)
history.append(result)Notice the two seeds recorded in successful results: the sampler seed identifies the sequence of sampled candidates, while the candidate seed identifies the training run. This is a useful provenance design for comparing methods locally. It does not establish that the paper used the same seeding scheme.
A tutorial experiment might declare three trials for a quick demonstration, but that would be a tutorial budget only. The paper's statement that 54 configurations were evaluated must not be reverse-engineered into a presumed RS trial count or into a presumed division between model families and optimizers.
GWO: population-guided proposals
GWO works with a matrix of wolf positions. A position is a numeric vector whose coordinates correspond to the ordered parameters in SearchSpace. The best three successful candidates are called alpha, beta, and delta in the generated implementation. Their encoded vectors guide a new population. After each update, positions are clipped to the declared bounds and decoded back into valid typed candidates.
The generated update function accepts positions with shape population × encoded_dimension. Each leader vector has shape encoded_dimension. The update is intentionally documented as a local GWO-style choice because no canonical GWO equations or settings were supplied in the paper:
def update_wolf_positions(
positions: Any,
alpha: Any,
beta: Any,
delta: Any,
iteration: int,
total_iterations: int,
rng: random.Random,
) -> np.ndarray:
"""Return one local GWO-style update for every wolf.
``positions`` has shape ``(population, encoded_dimension)`` and each leader
has shape ``(encoded_dimension,)``. The implementation uses the usual
linearly decreasing exploration coefficient and independent random draws.
These update rules are implementation decisions, not equations supplied by
the paper.
"""
current = np.asarray(positions, dtype=float)
leader_arrays = [np.asarray(value, dtype=float) for value in (alpha, beta, delta)]
if current.ndim != 2:
raise ValueError("positions must have shape (population, encoded_dimension)")
if current.shape[0] < 1:
raise ValueError("positions must contain at least one wolf")The remainder of the function validates leader shapes and iteration bounds, generates independent random coefficients, averages the three leader-guided proposals, and returns an array with the same population-by-dimension shape. The linearly decreasing exploration coefficient, random update details, and alpha/beta/delta ranking are implementation decisions rather than recovered paper settings.
Decoding mixed parameters safely
A continuous vector cannot directly be used as a batch size or a model-family name. CandidateCodec bridges that gap. It encodes typed values into a stable vector ordering and decodes them by clipping to the domain, rounding integer-like coordinates, selecting categorical positions, and constructing validated ModelConfig and TrainingConfig records.
The public decoding contract is concise:
def decode(self, vector: Sequence[float] | np.ndarray) -> CandidateConfig:
array = np.asarray(vector, dtype=float)
if array.ndim != 1 or array.shape[0] != self.dimension:
raise ValueError(
f"encoded candidate must have shape ({self.dimension},); received {array.shape}"
)
if not np.all(np.isfinite(array)):
raise ValueError("encoded candidate must contain only finite values")
values = {
name: self.space.domains[name].decode_value(float(array[index]))
for index, name in enumerate(self.space.parameter_names)
}
return self._build_candidate(self.space.validate_candidate(values))For example, a vector coordinate intended for batch sizes (8, 16, 32) is clipped to the valid index range and rounded to one of those positions. That prevents GWO from producing an invalid fractional batch size. It also means that several nearby continuous positions can decode to the same typed candidate; this is an expected consequence of using a continuous optimizer for mixed parameters.
Worked configuration example
The following excerpt is a small configuration-only example based on the generated search-contract test. It demonstrates integer, continuous, and discrete domains without asserting that these bounds came from the paper:
PARAMETER_DOMAINS = {
"stream_layers": ParameterDomain.integer(1, 3),
"units": ParameterDomain.integer(8, 32),
"dropout": ParameterDomain.continuous(0.0, 0.5),
"learning_rate": ParameterDomain.continuous(1.0e-4, 1.0e-2),
"batch_size": ParameterDomain.discrete((8, 16, 32)),
"epochs": ParameterDomain.integer(1, 4),
}
space = SearchSpace(domains=PARAMETER_DOMAINS)
codec = CandidateCodec(space)Here, stream_layers, units, batch_size, and epochs are discrete choices, while dropout and learning_rate vary continuously. codec can translate a validated CandidateConfig into a numeric vector for GWO and decode a clipped vector back into a typed candidate. The displayed ranges are deliberately modest tutorial settings; they are not paper-reported bounds.
In a complete local run, the same space and shared objective can be passed to RS or GWO. The pipeline functions run_random_search and run_gwo_search bind both methods to a chronological training/validation split created from the prepared training partition. Their documented contract excludes PreparedData.test from selection, so the held-out test data are reserved for evaluation after a candidate has been chosen.
Practical limits of the search reproduction
A fair local comparison requires RS and GWO to use the same feature order, scaling policy, candidate-training routine, validation partition, objective, and failure policy. Only the proposal mechanism should differ. The generated pipeline records the validation fraction as a local choice and shares the candidate objective, but this does not recover the paper's undisclosed validation protocol.
The paper's GWO superiority claim remains a reported paper result, not a result established by this scaffold. In particular, the generated GWO update, population size, iteration count, bounds, initialization, and discrete decoding cannot be labeled as the paper's implementation. The four-part labels in LSTM-GWO (1-1-0-1), GRU-GWO (2-1-1-1), and SRU-GWO (2-2-0-0) also remain opaque; the search code does not decode them.
The related contract tests are intended to check local invariants such as candidate decoding, seeded RS sampling, GWO shape and bound handling, and the common objective interface. They were not executed in this run. Static verification and semantic code verification were also skipped, so the generated files should be reviewed before relying on them operationally. No search, training run, or reproduction result is claimed here.
Held-out evaluation, metrics, and residual analysis
How do we know whether a forecast is useful? First, each prediction must be paired with the correct held-out observation. Then both arrays must be evaluated under the same scale and metric conventions. The evaluation layer therefore aligns predictions and targets, optionally restores original price units, computes the seven metrics named by the paper, and summarizes residual behavior.
This section distinguishes three kinds of statements. The paper names the metrics and discusses error distributions, but it does not provide canonical formulas, denominator rules, residual signs, or plotting conventions. The generated implementation supplies explicit conventional choices for those missing details. The resulting values would be local evaluation outputs—not verified reproductions of the paper's reported results.
From model output to aligned evaluation arrays
The generated predict_dataset function runs inference in loader order. It switches the model to evaluation mode so dropout is disabled, uses no-gradient inference, collects scalar predictions and targets batch by batch, and restores the model's previous training-mode flag afterward. Its scalar-target contract accepts arrays shaped samples or samples × 1. It does not reorder or silently truncate samples.
The key alignment operation is exposed separately as align_predictions_and_targets. It rejects unequal sample counts and preserves the order emitted by the loader. This is why held-out loaders should normally use shuffle=False: a shuffled loader can still produce pairs within a batch, but it no longer represents the original chronological output order.
The following excerpt is copied from predict_dataset. Notice the two independent checks: the model output and the batch targets are normalized to the scalar-target representation, and their batch counts must agree before either is appended.
outputs = model(inputs)
if not isinstance(outputs, Tensor):
raise TypeError(
f"model output for batch {batch_index} must be a tensor"
)
predictions, _ = _normalise_scalar_targets(outputs, "predictions")
batch_targets, _ = _normalise_scalar_targets(targets, "targets")
if predictions.shape[0] != batch_targets.shape[0]:
raise ValueError(
f"batch {batch_index} prediction and target counts differ: "
f"{predictions.shape[0]} != {batch_targets.shape[0]}"
)
prediction_parts.append(predictions)
target_parts.append(batch_targets)The function returns predictions in loader order, while the targets are retained internally for alignment validation. The model input remains the package-wide batch × time × 5 contract; this evaluation helper specifically supports the configured scalar-output case. A multivariate target would require an explicit extension rather than an implicit interpretation.
Choosing the evaluation scale
The paper states that inputs are scaled to [0, 0.95], but it does not say whether its reported metrics were computed in normalized space or after inverse transformation to price units. The generated pipeline exposes both choices through EvaluationConfig.
In normalized evaluation, actual_targets and predictions remain in scaled space. RMSE then has normalized units. In original-unit evaluation, inverse_transform_target embeds each scalar target into a five-feature row, applies FeaturewiseMinMaxScaler.inverse_transform, and extracts the configured target column. RMSE can then be interpreted in price units. MAPE, RMSPE, and PBIAS remain percentage-valued under the local conventions, while agreement metrics remain unitless.
The target column itself is also unresolved by the paper. The generated pipeline requires a configured choice such as Close; it does not assume that the paper's phrase “stock price” uniquely means Close or adjusted Close. The forecast horizon is likewise recorded separately from the input window_length.
The following excerpt is copied from inverse_transform_target and shows why the scaler needs a target index even though the prediction contains only one value per sample.
embedded = np.zeros((flat_values.shape[0], scaler.n_features), dtype=np.float64)
embedded[:, target_index] = flat_values
restored = scaler.inverse_transform(embedded)[:, target_index]
if len(original_shape) == 1:
return restored
return restored.reshape(original_shape)This operation preserves the prediction shape while using the scaler's five feature-specific statistics. It is an implementation mapping, not a formula recovered from the paper. The scaler must already be fitted, and its fitting policy is recorded as training-only in the generated preprocessing pipeline to reduce leakage.
The seven named metrics
The paper names R², RMSE, MAPE, RMSPE, PBIAS, Willmott Index, and NSE. Because no canonical equation records were supplied, the generated metrics.py module documents and implements conventional local definitions. The important practical point is consistency: every candidate must use the same scale, alignment rule, sign convention, and zero-denominator policy.
R²summarizes explained variation relative to an actual-value mean baseline. The generated function stores the conventional result on the unit-interval scale by default. A display convention can multiply it by 100, which supports the paper's percentage-style reporting. Thus a paper display of99.2427%is distinct from the underlying unit-interval representation0.992427.RMSEmeasures the typical magnitude of prediction error in the units supplied to the function. It is therefore scale-sensitive: normalized inputs yield normalized-unit RMSE, while inverse-transformed prices yield price-unit RMSE.MAPEaverages absolute relative errors and returns a percentage. It cannot divide by zero actual values, soEvaluationConfigselects whether such observations raise an error, are ignored, or receive a defined local treatment.RMSPEis the root-mean-square version of relative error and uses the same zero-actual policy.PBIASsummarizes signed aggregate bias. The generated default residual direction isactual_minus_predicted, so positive PBIAS indicates underprediction under that convention.Willmott Index measures agreement using the generated module's selected standard convention. Its denominator can be undefined for degenerate data, in which case the local implementation returns
NaN.NSE, or Nash–Sutcliffe Efficiency, compares squared forecast error with variation around the actual-series mean. LikeR², it is undefined when the actual series has no variation.
The module's report assembler keeps these choices together. The following excerpt is copied from compute_metric_report; it shows that one configuration controls the conventions used by the individual metric functions.
_validate_zero_policy(str(zero_policy))
return {
"r_squared": r_squared(actual_vector, predicted_vector, str(r2_convention)),
"rmse": rmse(actual_vector, predicted_vector),
"mape": mape(actual_vector, predicted_vector, str(zero_policy)),
"rmspe": rmspe(actual_vector, predicted_vector, str(zero_policy)),
"pbias": pbias(actual_vector, predicted_vector, str(pbias_convention)),
"willmott_index": willmott_index(
actual_vector,
predicted_vector,
str(willmott_convention),
),
"nash_sutcliffe_efficiency": nash_sutcliffe_efficiency(actual_vector, predicted_vector),
}Before reaching this block, _aligned_arrays converts supported array-like inputs to finite one-dimensional vectors and rejects mismatched shapes. This failure behavior matters: a metric calculated from mispaired samples can look numerically plausible while describing the wrong forecast errors.
A small illustrative interface example
The following excerpt is adapted directly from the generated metric test's synthetic setup. It demonstrates the intended interface with short local arrays; it is not HSI data, and no numerical result from this example should be compared with the paper's benchmarks. The test uses EvaluationConfig() to select the generated defaults rather than claiming that those defaults were specified by the paper.
actual = np.array([100.0, 105.0, 110.0, 115.0], dtype=float)
predicted = np.array([99.0, 106.0, 109.0, 116.0], dtype=float)
# Defaults are deliberately supplied by EvaluationConfig rather than
# inferred from the paper, whose metric conventions are unspecified.
report = compute_metric_report(actual, predicted, EvaluationConfig())A corresponding residual summary can be created from the same aligned arrays. In the generated implementation, compute_residuals(actual, predicted) returns actual - predicted with the same shape, and summarize_residuals flattens the result before calculating its count, mean, population standard deviation, standardized skewness, and excess kurtosis. Fewer than three observations, or zero variance, leave skewness undefined; fewer than four observations, or zero variance, leave excess kurtosis undefined. These estimator and insufficiency rules are implementation decisions because the paper only mentions mean, standard deviation, skewness, and kurtosis without defining their conventions.
Residuals and visual analysis
Residuals make systematic behavior easier to inspect than a single score. Under the generated actual_minus_predicted convention, positive residuals indicate underprediction and negative residuals indicate overprediction. compare_error_distributions creates a stable table for named residual groups, preserving empty groups with an explicit count and undefined summary values rather than inventing observations.
The visualization adapters implement the analysis types mentioned by the paper:
plot_error_boxplotsshows grouped residual spread and central tendency.plot_error_violinsshows distribution shape.plot_metric_comparisongives each metric its own panel so percentage and unitless measures are not placed on one misleading axis.plot_pbias_radardisplays signed PBIAS values using a locally selected radial convention.plot_taylor_summaryuses a locally selected correlation-angle and standard-deviation representation.
These functions operate on local predictions and reports. They do not contain the paper's plotted points, styling, axes, or source predictions. Therefore they can support analogous analysis, but they cannot claim exact reproduction of the paper's figures.
Evaluate only after model selection
The evaluation pipeline enforces the intended experiment order conceptually: train candidates and compare them using the configured validation objective, select a candidate, and only then read the chronological held-out partition for final metrics. evaluate_training_run evaluates a retained trained model. evaluate_search_result requires that a selected trained model be retained; it does not silently retrain from an incomplete search record.
The resulting EvaluationRun stores predictions, aligned actual targets, residuals, the metric report, and provenance. Provenance includes the evaluation scale, target column, forecast horizon, window length, metric conventions, residual sign, held-out sample count, and preprocessing metadata. This makes a future comparison auditable: a difference in RMSE can be investigated as a possible scale, target, or convention difference rather than treated as unexplained model behavior.
The paper's seven metrics and its discussion of residual distributions are therefore represented, but not overclaimed. The formulas and conventions are local implementation choices, the target and evaluation scale remain configurable, and no execution or verification occurred in this run. Any future local report must be labeled as a computed result and kept separate from the paper's reported benchmark records.
Reported LSTM-GWO, GRU-GWO, and SRU-GWO benchmarks
How should you use the paper’s reported numbers when the original experiment cannot yet be reconstructed exactly? Treat them as reference records, not as expected outputs that automatically validate a new run. The generated package stores the reported values separately from locally computed predictions and metrics, so a later experiment can be compared with the paper without confusing the two.
What the paper reports
The paper identifies its strongest reported configuration as LSTM-GWO (1-1-0-1). The four-part label is opaque: the supplied paper context does not define whether its fields represent layer counts, dropout settings, dense layers, or another encoding. The label must therefore remain a benchmark identifier rather than being translated into ModelConfig values.
The reported LSTM-GWO metrics are:
R²:
99.2427%RMSE:
339.3902MAPE:
1.1721%RMSPE:
1.6221%PBIAS:
0.0523Willmott Index:
0.9981NSE:
0.9924
The paper also reports the following best labels and associated values:
GRU-GWO `(2-1-1-1)`: R²
99.2322%, RMSE341.7225, MAPE1.1821%, and PBIAS−0.1357.SRU-GWO `(2-2-0-0)`: R²
99.2009%, RMSE348.6384, and MAPE1.2080%.
The remaining GRU and SRU metric associations are not clear in the supplied extraction. They are intentionally left unavailable rather than inferred from nearby text or reconstructed tables. Likewise, the configuration labels remain undecoded.
These values also use mixed display conventions. R² is shown by the paper as a percentage, whereas Willmott Index and NSE are shown on a unit-interval scale. A local metrics implementation may store R² internally as 0.992427 and display it as 99.2427%, but that conversion must be recorded explicitly before comparing values.
How the generated code preserves the evidence
PaperBenchmark is an immutable record containing the recurrent family, optimizer, opaque configuration label, metric values, units, and provenance. Its metric schema always contains the seven named metrics, but unavailable values are represented by None. The following excerpt is copied from reported_benchmarks in src/compositional_rnn_stock/evaluation/benchmarks.py:
def reported_benchmarks() -> tuple[PaperBenchmark, ...]:
"""Return the three benchmark records supplied in the paper context.
Missing GRU and SRU associations remain ``None`` rather than being inferred from
other reported values. R² is stored in the paper's percentage display convention.
"""
return (
PaperBenchmark(
family="LSTM",
optimizer="GWO",
configuration_label="1-1-0-1",
metrics=_benchmark_metrics(
r_squared=99.2427,
rmse=339.3902,
mape=1.1721,
rmspe=1.6221,
pbias=0.0523,
willmott_index=0.9981,
nash_sutcliffe_efficiency=0.9924,
),
metric_units=dict(_METRIC_UNITS),
),The function returns records marked as supplied paper targets. It does not train a model, generate predictions, or assert that any local candidate achieved these values. The LSTM record is complete, while the later GRU and SRU records retain None for unavailable associations.
The generated contract test makes this distinction explicit when it retrieves the records:
benchmarks = reported_benchmarks()
assert [(item.family, item.optimizer, item.configuration_label) for item in benchmarks] == [
("LSTM", "GWO", "1-1-0-1"),
("GRU", "GWO", "2-1-1-1"),
("SRU", "GWO", "2-2-0-0"),
]This is a planned test file, not evidence of an executed check. Under the authoritative run policy, code execution and verification were disabled.
Comparing a future local report
The compare_to_report function accepts a local metric mapping and one PaperBenchmark. It produces rows only when both sides have a value. Differences are expressed as local minus reported in the benchmark’s displayed units. Thus a missing GRU RMSPE value does not become a guessed comparison row, and a local unit-interval R² can be converted to the paper’s percentage convention when the local report has not declared another unit.
A comparison is meaningful only when the upstream experiment is also aligned. The provenance should identify the target column, forecast horizon, scaling range and fitting policy, window length, chronological split, recurrent-family configuration, seed, training settings, search settings, evaluation scale, and metric conventions. Without those fields, a numerical difference cannot reveal whether the models or datasets were actually comparable.
The evaluation pipeline places these comparisons alongside a local EvaluationRun, whose predictions, aligned targets, residuals, metric report, and provenance are kept together. This connects benchmark comparison to the feature-wise compositional model: the local prediction must first come from the five independent OHLCV streams, concatenation fusion, and the selected recurrent and dense head. A benchmark difference cannot repair an architecture or target choice that the paper leaves unspecified.
Reported evidence versus local conclusions
The paper states that GWO outperformed Random Search across the evaluated LSTM, GRU, and SRU families. That statement is reported evidence from the paper. It is not a result established by this unexecuted scaffold. A local RS-versus-GWO comparison would require the same preprocessing, validation objective, candidate space, and declared search budget for both methods, followed by actual execution and evaluation.
The safe interpretation is therefore:
reported_benchmarks()preserves the supplied reference records.compare_to_reportprovides a labeled comparison mechanism for future local outputs.Missing metrics and opaque configuration labels remain unresolved.
No local metric is claimed to match the paper.
The benchmark records are useful precisely because they retain their provenance and limitations. They provide targets for a future, better-specified reproduction rather than proof that the current configurable implementation reproduces the published experiment.
Offline synthetic demonstration and artifact provenance
How can you learn the repository’s data and model interfaces without downloading market data? Use the offline demonstration. It creates clearly labeled synthetic OHLCV rows, applies the same five-channel scaling and windowing contracts, builds a configurable compositional model, and inspects the expected tensor shapes. This is a plumbing demonstration only: the synthetic series is not Hang Seng Index data and cannot validate forecasting performance.
Why use an offline demonstration?
The paper describes daily Hang Seng Index history obtained from Yahoo Finance, but the supplied context does not identify a unique ticker, date range, adjustment policy, or download configuration. Yahoo Finance data can also change as providers revise historical records. An offline example avoids those dependencies while making every local choice visible.
The generated example uses a deterministic pseudo-random generator when given a selected seed. It creates five columns in the canonical order Open, High, Low, Close, Volume. The values are synthetic prices and volumes; they are not intended to reproduce the empirical distribution of the HSI.
The example’s generator is defined in examples/offline_synthetic_demo.py:
def make_synthetic_ohlcv(num_days: int, seed: int) -> pd.DataFrame:
"""Create deterministic, clearly labeled synthetic OHLCV observations.
The returned table has shape ``(num_days, 5)`` and canonical column order:
Open, High, Low, Close, Volume. Values are generated only for this
demonstration; they are not intended to model the empirical HSI series.
"""
if isinstance(num_days, bool) or not isinstance(num_days, int) or num_days <= 0:
raise ValueError("num_days must be a positive integer")
if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
raise ValueError("seed must be a non-negative integer")
rng = np.random.default_rng(seed)
daily_returns = rng.normal(loc=0.0004, scale=0.012, size=num_days)
close = 18_000.0 * np.exp(np.cumsum(daily_returns))
open_noise = rng.normal(loc=0.0, scale=35.0, size=num_days)
open_price = close + open_noise
intraday_spread = np.abs(rng.normal(loc=55.0, scale=18.0, size=num_days))
high = np.maximum(open_price, close) + intraday_spread
low = np.minimum(open_price, close) - intraday_spread
volume = np.maximum(
1.0,
1_000_000.0 + rng.normal(loc=0.0, scale=120_000.0, size=num_days),
)
values = np.column_stack((open_price, high, low, close, volume))
return pd.DataFrame(
values,
index=pd.date_range("2020-01-01", periods=num_days, freq="D"),
columns=list(CANONICAL_OHLCV_COLUMNS),
)The function accepts a positive number of rows and a non-negative integer seed. Its output is a table with shape num_days × 5. The validation errors are useful failure cases: a boolean is not accepted as an integer, and invalid row counts or seeds are rejected rather than silently corrected.
Scaling an earlier chronological portion
The paper states the intended feature range [0, 0.95], but it does not specify exactly which observations fit the scaling statistics. The demonstration chooses an earlier chronological portion for fitting. This is a leakage-prevention decision made by the implementation, not a recovered paper detail. The remaining observations are transformed with the already fitted scaler.
The relevant portion of main is:
data_config = DataConfig(
target_column="Close",
forecast_horizon=1,
window_length=20,
split_ratio=0.8,
scaling_range=(0.0, 0.95),
)
raw_frame = make_synthetic_ohlcv(num_days=96, seed=7)
raw_values = raw_frame.loc[:, list(CANONICAL_OHLCV_COLUMNS)].to_numpy(
dtype=np.float64
)
# Fit on an earlier chronological portion to demonstrate a leakage-avoiding
# choice. The paper states the [0, 0.95] range but does not specify fit scope.
scaler_split = int(raw_values.shape[0] * data_config.split_ratio)
scaler = FeaturewiseMinMaxScaler(feature_range=data_config.scaling_range)
scaler.fit(raw_values[:scaler_split])
scaled_values = scaler.transform(raw_values)Here, target_column='Close', forecast_horizon=1, and window_length=20 are tutorial configuration choices. The paper does not identify the target column or forecast horizon; it only describes 20-day and 40-day input windows. Similarly, the demonstration’s 96 synthetic rows and seed 7 are not paper data.
FeaturewiseMinMaxScaler requires a final feature axis of length five. It stores separate scaling metadata for each OHLCV channel, so Volume does not determine the scaling of the price columns. Its inverse_transform method is available when later evaluation should return predictions to original price units. The example does not calculate or present forecast metrics.
Building windows and inspecting model shapes
After scaling, the example selects the configured target column and constructs overlapping supervised windows:
target_index = list(CANONICAL_OHLCV_COLUMNS).index(data_config.target_column)
windowed = make_sliding_windows(
scaled_values,
target_index=target_index,
window_length=data_config.window_length,
forecast_horizon=data_config.forecast_horizon,
timestamps=raw_frame.index.to_numpy(),
)
# The model follows the paper's feature-wise structure: five independent
# recurrent streams, concatenation fusion, and a one-value prediction head.
model_config = ModelConfig(
recurrent_family="LSTM",
stream_layers=1,
stream_units=16,
stream_dropout=0.0,
fusion_dropout=0.0,
use_post_fusion_recurrent=False,
dense_layers=(16,),
output_dimension=1,
)
model = build_compositional_model(model_config)
model.eval()
batch_size = min(4, windowed.sample_count)
batch_inputs = torch.as_tensor(windowed.inputs[:batch_size], dtype=torch.float32)
with torch.no_grad():
predictions = model(batch_inputs)make_sliding_windows returns inputs with shape samples × window_length × 5 and scalar targets with shape samples. With this tutorial configuration, each input contains 20 time steps and five channels. The target is the selected Close value one step after the input window according to the implementation’s explicit alignment convention.
build_compositional_model then constructs the feature-wise recurrent model. Each batch input has shape batch × 20 × 5; the model separates the five channels, encodes them independently, concatenates their representations, and returns a prediction with shape batch × output_dimension. The model configuration shown here is an educational choice. It does not decode the paper’s opaque benchmark label LSTM-GWO (1-1-0-1), nor does it claim to recover the paper’s exact layer widths or training recipe.
The source example prints these contracts:
print("Synthetic demonstration only; no paper result is being reproduced.")
print(f"raw OHLCV shape: {raw_values.shape}")
print(f"scaled OHLCV range: {data_config.scaling_range}")
print(
"window input shape (samples, time_steps, features): "
f"{windowed.inputs.shape}"
)
print(f"window target shape: {windowed.targets.shape}")
print(
"model batch input shape (batch, time_steps, features): "
f"{tuple(batch_inputs.shape)}"
)
print(f"model prediction shape (batch, output_dimension): {tuple(predictions.shape)}")These statements document intended dimensions; this run did not execute the example. In particular, no output numbers, predictions, or paper-comparison result should be inferred from the excerpt.
Recording provenance and arrays locally
A demonstration becomes more useful when its assumptions travel with its outputs. Artifact provenance should identify at least the data source, cleaning policy, scaling range and fit scope, window length, target column, forecast horizon, model family, architecture settings, seed, and evaluation conventions. For real HSI work, the archived raw file and the Yahoo Finance query parameters should also be retained.
The generated artifact module provides local persistence through save_json_record, save_array, and load_json_record. The README illustrates the JSON-record call as follows:
save_json_record(report, Path('artifacts/report.json'))A JSON record is appropriate for serializable configuration, acquisition metadata, histories, benchmark labels, and metric reports. Numeric predictions and residuals should be stored with save_array, which preserves their numeric shape and dtype. load_json_record reads a previously saved top-level JSON object. The artifact implementation uses local filesystem operations and rejects implicit overwrites, helping prevent an old experiment from being silently replaced.
For example, a future evaluation record could contain a local run identifier, window_length, target_column, forecast_horizon, scaler settings, model configuration, seed, metric conventions, and a separate reference to prediction and residual arrays. It should distinguish computed local values from the paper’s reported benchmark records. The generated interfaces provide the storage operations, while the exact record contents remain an experiment-provenance responsibility.
What this demonstration does not establish
The synthetic path confirms the intended software contracts conceptually: five ordered channels can be scaled, transformed into windows, and supplied to a feature-wise compositional model. It does not establish that the model forecasts the HSI accurately, that the chosen target and horizon match the paper, or that the reported LSTM-GWO, GRU-GWO, or SRU-GWO values can be reproduced.
No example execution, artifact creation, test execution, or verification occurred under the run policy. A stronger reproduction would archive the original Yahoo Finance data query and downloaded rows, supply the missing architecture and optimization details, run the configured experiment, and then evaluate predictions under explicitly documented metric conventions. Until then, the offline example is a reproducibility aid—not evidence of paper-level performance.
Verification status, limitations, and responsible reproduction claims
How can a carefully organized implementation be useful without overstating what it proves? The answer is to separate invariants, planned checks, and verified results. The generated package records the paper’s intended data flow, model structure, search interfaces, evaluation conventions, and provenance. However, this run did not execute code or perform verification. Its correct status is therefore verification_skipped: a configurable reproduction scaffold with explicit gaps, not a validated reproduction of the published experiment.
What the implementation is designed to preserve
Several structural requirements are clear enough to review independently of the paper’s missing experimental details:
The input feature order is exactly
Open,High,Low,Close,Volume.Model inputs use
batch × time × 5ordering.Preprocessing constructs chronological windows and keeps training samples before later samples.
The scaler targets the stated interval
[0, 0.95]. Fitting it on earlier training observations only is a leakage-prevention implementation choice; the paper does not specify its fitting partition.The model creates exactly five independent feature streams.
Stream representations are fused by concatenation, rather than by early summation or mixing.
Dropout is intended for training mode and should be inactive during evaluation.
Predictions and targets must remain sample-aligned before metric calculation.
Search candidates must decode to valid typed values for layer counts, units, dropout rates, learning rates, batch sizes, and epochs.
RS and GWO should use the same candidate objective, preprocessing, and validation protocol when compared locally.
Benchmark labels such as
LSTM-GWO (1-1-0-1)remain opaque identifiers and are not translated into architecture settings.
These are implementation contracts and interpretation safeguards. They are not evidence that a particular model reached the paper’s reported metrics.
Planned contract tests are not completed checks
The repository contains generated test files intended to make these invariants reviewable later. For data handling, test_scaler_range_and_inverse_round_trip, test_window_target_alignment, and test_chronological_split_has_no_reordering address scaling behavior, window-to-target indexing, and chronological partitioning. The model tests include test_model_accepts_five_feature_input, test_fusion_dimension_is_additive, test_recurrent_family_dispatch_is_consistent, and test_dropout_differs_only_in_training_mode.
The remaining planned tests cover local metric conventions, candidate encoding, search bounds, and provenance. Examples include test_rmse_zero_for_equal_arrays, test_percentage_metric_zero_policy, test_candidate_codec_validates_discrete_parameters, test_gwo_positions_stay_in_bounds_after_decoding, test_reported_benchmarks_preserve_supplied_values, and test_incomplete_metrics_remain_missing.
These names describe intended checks, not completed checks. Under the authoritative run policy, test generation and execution were disabled. No test result, passing assertion, successful import, or model output should be inferred from the presence of these files.
Static and semantic verification status
Static verification and semantic verification address different questions. Static review would inspect syntax, imports, public interfaces, tensor-shape contracts, and configuration propagation without running the experiment. Semantic review would assess whether the implementation’s behavior matches the intended method, including feature independence, concatenation, dropout mode changes, search-objective consistency, and metric conventions.
Both forms of verification were skipped here. Local static verification reports the status verification_skipped, and semantic code verification was also skipped. Code execution, test execution, tutorial-section verification, and final quality review were disabled by policy. Consequently, this section must not claim that the generated code is correct, that the tests pass, or that any local result matches the paper.
Why exact reproduction remains underdetermined
The supplied paper context does not uniquely determine several decisions that materially affect results:
The Yahoo Finance ticker or symbol, date range, download options, adjustment policy, missing-value handling, and duplicate-row handling.
The forecast target and horizon. The paper names “stock price” and lists OHLCV inputs, but does not identify whether the target is
Close, adjustedClose, another price, or a multivariate output.The exact interpretation of the 20-day and 40-day windows beyond treating them as input lengths.
The normalization formula, fitting partition, clipping behavior, and inverse-transformation procedure.
Recurrent layer counts, widths, activations, hidden-state extraction, dense-layer structure, and exact placement of both dropout stages.
The SRU variant and Python implementation or API.
The training loss, gradient optimizer, initialization, learning-rate schedule, seed, shuffling policy, stopping rule, and checkpoint-selection rule.
RS search domains, distributions, trial budget, and validation protocol.
GWO population size, initialization, bounds, iteration count, update schedule, objective, and encoding of discrete parameters.
Metric formulas, percentage conventions, zero-denominator handling, bias signs, and the scale used for evaluation.
The paper’s reported total of 54 configurations does not resolve these omissions. Nor can the opaque four-part labels in the LSTM-GWO, GRU-GWO, and SRU-GWO records be safely decoded from the supplied evidence.
A responsible review checklist
Before treating a future local run as evidence, review the following items and record each one in the experiment provenance:
Data shape: confirm that the prepared inputs use
batch × time × 5and the canonical OHLCV order.Chronology: confirm that the split and window-target alignment preserve temporal order and do not use future observations during fitting.
Scaling: record the
[0, 0.95]range, the fitting partition, constant-feature policy, and any inverse transformation.Target semantics: record the target column and forecast horizon instead of assuming that the paper specifies them.
Model structure: record the five independent encoders, recurrent family, representation choice, dropout placement, fusion operation, post-fusion layers, and output dimension.
Training: record the loss, gradient optimizer, learning rate, batch size, epochs, seed, validation protocol, and checkpoint policy.
Search: record the typed domains, objective direction, trial or wolf budget, bounds, discrete decoding, and failure handling.
Evaluation: record whether metrics use normalized or original units, every metric convention, percentage display rules, and zero handling.
Benchmarks: preserve reported values separately from local values, keep missing GRU and SRU associations missing, and leave configuration labels opaque.
Verification: record whether code and tests were actually executed and reviewed; do not substitute planned test names for evidence.
This checklist is a review target, not a statement that the items were checked in this run.
What a stronger reproduction would require
A stronger claim would require the original data query or an archived copy of the downloaded OHLCV data, the complete architecture and configuration tables, the exact target and forecast horizon, and the full training and validation protocol. It would also require the RS and GWO search spaces and budgets, the selected SRU implementation, canonical metric definitions, and the original prediction or error outputs needed to compare visual analyses.
After those details were supplied, the implementation could use its provenance records to replace local choices without changing the overall public interfaces. The resulting experiment would still need to be executed, checked, and compared under a documented protocol before it could be described as a reproduction.
Attention mechanisms, hybrid architectures, and technical indicators are mentioned as future work in the paper. They are outside this reproduction workflow and should not be added to close the evidence gaps.
The appropriate conclusion at this stage is deliberately modest: the package makes the paper-to-code assumptions visible and organizes the intended pipeline, but neither the generated scaffold nor the supplied benchmark constants establish successful or exact reproduction.
Use the button or URL below to download the source code.


