Onepagecode

Onepagecode

Quant Trading: Deep GRU Recurrent Neural Networks for Stock Price Forecasting (Python Guide)

Building an end-to-end multi-layer GRU time-series model for weekly stock return prediction on AAPL and MSFT.

Onepagecode's avatar
Onepagecode
Aug 06, 2026
∙ Paid

Use the URL to download the source code!

The paper develops and evaluates separate GRU recurrent neural-network models for forecasting weekly adjusted closing prices of Apple Inc. (AAPL) and Microsoft Corporation (MSFT). Each series contains 263 observations from Yahoo Finance. The workflow consists of exploratory analysis, missing-value and outlier checks, decomposition, ADF stationarity testing, autocorrelation analysis, three-period sliding-window transformation, training-only MinMax normalization, chronological splitting, Hyperband-based hyperparameter tuning, GRU training with Adam, early stopping and checkpointing, inverse transformation, and validation forecasting evaluation with MSE, MAE, MAPE, and forecast accuracy. The selected AAPL model has three GRU layers with 240, 16, and 16 units; the selected MSFT model has two GRU layers with 224 and 48 units. Reported MAPE values are 3.37% for AAPL and 2.55% for MSFT.

This Substack is reader-supported. To receive new posts and support my work, consider becoming a free or paid subscriber.

Implementation Assumptions

  • Use appendix records as the primary local data source when the exact Yahoo Finance retrieval and weekly aggregation procedure cannot be established.

  • Represent each raw asset series as 263 chronological observations and each windowed dataset as 260 samples with shape (260, 3, 1).

  • Use chronological sample partitions of 156 training, 52 testing, and 52 validation samples.

  • Keep AAPL and MSFT data, scalers, models, histories, checkpoints, and metrics separate.

  • Treat exploratory decomposition, ADF testing, autocorrelation, histograms, and boxplots as diagnostics only; they do not modify forecasting inputs.

  • Use one explicit, documented MinMaxScaler convention for reproducibility, while exposing the convention because the paper does not specify whether raw training prices or flattened window values were used.

  • Use the Table 1 configurations as the final reproduction targets: AAPL widths (240, 16, 16), MSFT widths (224, 48), zero dropout, learning rate 0.001, MSE, batch size 8, maximum 100 epochs, and patience 7.

  • Keep the testing subset distinct from validation until an explicit evaluation-subset option is selected.

  • Do not claim exact paper metric reproduction because source versions, tuner space, random seed, callback details, and evaluation protocol are incomplete.

  • No code execution, tests, static verification, semantic verification, tutorial verification, or final review is claimed under the supplied run policy.

Scope, Paper Facts, and Reproducibility Limits

What exactly is being reproduced? The paper forecasts the next weekly adjusted closing price for two stocks: Apple, identified as AAPL, and Microsoft, identified as MSFT. Each stock is treated as its own univariate time series. In other words, the AAPL model does not receive MSFT prices, and neither model uses technical indicators, sentiment, volume, macroeconomic variables, or other explanatory features.

The model sees a short ordered history of adjusted prices and produces one next-price forecast. Adjusted closing prices, rather than ordinary closing prices, are the paper's retained price field because they account for effects such as dividends and stock splits. This distinction matters: substituting ordinary closing prices would change the input data and would no longer be the same experiment.

Three evidence categories

It helps to separate what is known from the paper, what follows mathematically from the data dimensions, and what the implementation must choose.

Paper facts include 263 weekly adjusted closing-price observations for each asset, separate GRU models, a three-period sliding window, chronological partitioning, training-only MinMax normalization, Hyperband-based tuning, Adam optimization, early stopping, checkpointing, and evaluation with MSE, MAE, MAPE, and forecast accuracy. The final reported AAPL architecture has GRU widths of 240, 16, and 16. The final MSFT architecture has widths of 224 and 48. Both configurations report zero dropout, a learning rate of 0.001, MSE loss, batch size 8, a maximum of 100 epochs, and patience 7.

Derived dimensional correction. Windowing creates supervised examples before splitting. Three preceding prices are needed to predict one following price. Therefore, 263 raw observations produce 260 supervised samples. The paper-consistent chronological partition is consequently 156 training samples, 52 testing samples, and 52 validation samples. Each recurrent input has shape (n, 3, 1): n examples, three time steps, and one price feature. Each target is standardized in the generated API as shape (n, 1), one scalar target per example.

Implementation decisions. Several details are absent from the supplied paper text. The generated project uses local appendix-style CSV files instead of inventing a Yahoo Finance download and weekly-resampling procedure. It exposes the choice between the testing and validation subsets rather than silently combining them. It also records a scaler convention, diagnostic parameters, Keras layer details, callback behavior, and tuner search ranges as implementation choices rather than paper facts.

No canonical equation records were supplied. This section therefore explains the tensor contracts, algorithm responsibilities, and data flow in prose and code. It does not reconstruct GRU gate equations, scaling formulas, loss formulas, or metric formulas.

The end-to-end reproduction boundary

The package is organized as a sequence of isolated per-asset stages:

  1. Load and validate one local adjusted-price series.

  2. Run exploratory diagnostics without modifying the forecasting data.

  3. Convert 263 prices into 260 three-step supervised windows.

  4. Split those windows chronologically into 156, 52, and 52 samples.

  5. Fit normalization using training data only and transform the later subsets.

  6. Build the selected AAPL or MSFT GRU architecture.

  7. Optionally run asset-specific Hyperband tuning, or instantiate the reported final configuration directly.

  8. Train with Adam, validation-loss monitoring, early stopping, and checkpointing.

  9. Select either the testing or validation subset explicitly.

  10. Inverse-transform forecasts and targets, calculate metrics, and compare them with the paper's Table 3 references.

The central orchestration function is run_asset_pipeline. Its inputs are an asset identifier, a local data directory, an output directory, an evaluation_subset value of either "validation" or "test", and a Boolean tune flag. Its output is an AssetRunResult containing the raw PriceSeries, diagnostic records, the WindowedDataset, scaled DatasetSplits, scaler, model, training result, and forecast result. This retained-artifact design makes the intermediate boundaries inspectable instead of reducing the workflow to a single opaque training call.

The pipeline rejects unsupported assets, missing data directories, invalid evaluation-subset names, and invalid tuning flags. The lower-level validation stage is responsible for checking the expected 263 rows, chronology, missingness, finite numeric values, and positive adjusted prices. A failure at these boundaries should stop the reproduction rather than silently repair or reinterpret the input.

Here is the public configuration interface that captures the paper's final model settings and reference values:

from stock_forecasting.config import (
    get_default_split_config,
    get_final_model_config,
    get_reference_metrics,
)

config = get_final_model_config("AAPL")
split = get_default_split_config()
reference = get_reference_metrics("AAPL")

get_final_model_config returns an immutable AssetModelConfig. For AAPL, its units are (240, 16, 16); for MSFT, they are (224, 48). It also carries the reported dropout, learning rate, loss name, batch size, epoch limit, and patience. get_default_split_config returns the derived 156/52/52 split and rejects configurations that do not total 260 samples. get_reference_metrics returns Table 3 values for comparison only; it does not calculate a forecast and does not claim that those values were achieved.

Worked example: trace one AAPL run

Assume that data/raw/AAPL.csv contains the normalized local input with columns date and adjusted_close, one asset per file, and 263 chronological rows. A complete AAPL pipeline call is:

from pathlib import Path

from stock_forecasting.pipeline import run_asset_pipeline

result = run_asset_pipeline(
    asset="AAPL",
    data_dir=Path("data/raw"),
    output_dir=Path("artifacts"),
    evaluation_subset="validation",
    tune=False,
)

This call expresses several important decisions. asset="AAPL" keeps the model univariate and isolated. evaluation_subset="validation" follows the paper's statement that validation forecasting was reported, but it does not consume or merge the separate testing subset. tune=False selects the reported final Table 1 configuration directly rather than pretending that the incomplete original Hyperband metadata can be recovered.

The resulting AssetRunResult retains the main stages:

raw_prices = result.series.prices
window_inputs = result.windowed.inputs
scaled_training_inputs = result.splits.train_inputs
trained_model = result.model
forecast = result.forecast
comparison = result.diagnostics["reference_comparison"]

The expected raw price array has length 263. The window input array has shape (260, 3, 1). The scaled training input array has 156 samples, while the testing and validation arrays each have 52. forecast contains date-aligned actuals, predictions, and metrics after inverse transformation. comparison contains computed, reference, and difference fields for the four reported metrics; it is a descriptive comparison, not a verification result.

The lower-level pipeline ordering is visible in the generated implementation. This excerpt shows the key dimensional transitions and the explicit scaler choice:

    # load_validate_price_series: local appendix records are the offline input.
    series = load_asset_records(data_dir, canonical_asset)
    quality = validate_price_series(series)
    diagnostics = _run_diagnostics(series)
    diagnostics["quality"] = quality

    # create_windowed_supervised_dataset: 263 raw observations become 260 samples.
    windowed = create_windowed_supervised_dataset(series, window_length=3)

    # chronological_split_and_minmax_scale: 156/52/52 is applied after windowing.
    split_config: SplitConfig = get_default_split_config()
    raw_splits = split_windowed_dataset(windowed, split_config)
    scaled_splits, scaler = _scale_splits(
        raw_splits,
        convention=ScalerConvention.RAW_TRAINING_PRICES,
    )

Notice that diagnostics are computed before modeling but are not passed into the GRU as features. Also notice that the split occurs after windowing and that the scaler is fitted through _scale_splits using training data. The generated pipeline records ScalerConvention.RAW_TRAINING_PRICES as its selected decision, while the paper itself leaves open whether the original implementation fitted on raw training prices or on flattened training windows and targets.

After preprocessing, the pipeline chooses the model configuration, builds the model, trains it, and evaluates the explicitly selected subset:

    model_config = _select_model_config(
        canonical_asset,
        scaled_splits,
        tune=tune,
        output_dir=output_dir,
    )
    diagnostics["model_config"] = model_config
    diagnostics["tuning_requested"] = tune

    model = build_asset_gru_model(model_config, input_shape=(3, 1))
    checkpoint_path = output_dir / canonical_asset / "best.weights.h5"
    checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
    training = train_gru_with_callbacks(
        model=model,
        splits=scaled_splits,
        config=model_config,
        checkpoint_path=checkpoint_path,
    )

    evaluation_inputs, evaluation_targets, evaluation_dates = select_evaluation_subset(
        scaled_splits,
        normalized_subset,
    )
    forecast = evaluate_forecasts(
        model=training.model,
        inputs=evaluation_inputs,
        targets=evaluation_targets,
        dates=evaluation_dates,
        scaler=scaler,
    )

The model consumes scaled inputs with shape (batch, 3, 1) and returns one normalized scalar per sample. evaluate_forecasts converts predictions and targets back to price units using the training-fitted scaler before producing the reported metrics. The evaluation_dates are the dates of the target observations, so plotted forecasts correspond to the prices they are intended to predict.

For convenience, run_both_assets repeats this workflow for AAPL and MSFT independently. It returns a dictionary keyed by ticker and uses validation evaluation by default. Calling run_asset_pipeline directly is the appropriate way to select the testing subset for a particular asset.

Command-line entry point

The generated script exposes the unresolved evaluation choice and optional tuning choice explicitly:

python scripts/run_reproduction.py \
  --data-dir data/raw \
  --output-dir artifacts \
  --evaluation-subset validation

To keep the distinct testing subset as the held-out evaluation target, use:

python scripts/run_reproduction.py \
  --data-dir data/raw \
  --output-dir artifacts \
  --evaluation-subset test

Adding --tune requests the optional asset-specific Hyperband path. The non-tuning path is the clearer reproduction target when the goal is to instantiate the paper's reported Table 1 architectures. The exact original tuner search space, random seed, number of Hyperband iterations, and stopping criteria are unavailable, so a locally selected configuration cannot be described as an exact recovery of the paper's search.

What the reference metrics do—and do not—mean

The paper reports the following Table 3 reference values: AAPL MSE 55.80, MAE 6.11, MAPE 3.37%, and forecast accuracy 96.63%; MSFT MSE 144.44, MAE 9.41, MAPE 2.55%, and forecast accuracy 97.45%. The generated compare_to_reference function reports differences between a local run and these values. It does not turn the comparison into a pass/fail claim unless a caller supplies a tolerance, and even then the result describes the selected run rather than proving scientific equivalence.

This Substack is reader-supported. To receive new posts and support my work, consider becoming a free or paid subscriber.

These values may differ because the exact source-data version, weekly aggregation procedure, scaler fitting convention, random initialization, unspecified Keras defaults, callback behavior, Hyperband search space, and evaluation-subset protocol are incomplete. The lower MSFT MAPE reference also illustrates why relative and absolute errors should not be conflated: a higher price level can produce larger absolute errors while still producing a lower percentage error.

Reproducibility and verification limits

This is a reproduction scaffold, not a claim that the original experiment has been exactly rerun. The supplied run policy disabled code execution, test generation, local static verification, semantic code verification, tutorial verification, and final quality review. The local verification record therefore reports a skipped status, not a passing result, and semantic code verification was also skipped. No commands, training runs, tests, generated metrics, or reference matches are claimed here.

The paper itself also limits interpretation. It uses only two technology stocks and 263 weekly observations per asset. A price-history-only GRU may smooth abrupt movements and can be affected by distribution shift. The paper does not supply rolling-origin evaluation, confidence intervals, statistical significance testing, or broad benchmark comparisons. Consequently, forecasts should be treated as decision-support information for analysis and risk management, not as an autonomous trading system.

The practical scope is therefore precise: preserve the two isolated adjusted-price series, reproduce the 260-sample dimensional interpretation, keep the 156/52/52 chronology and training-only scaling boundary, expose unresolved protocol choices, and label Table 1 and Table 3 as paper references. Any numerical conclusion should wait until execution and the relevant verification stages are actually enabled.

Local Adjusted-Price Inputs and Diagnostic Analysis

Before training a forecasting model, ask a simpler question: do we have the right observations in the right order? For this reproduction, each asset is an independent weekly sequence of adjusted closing prices. The diagnostic stage checks that sequence, summarizes it, and records useful time-series behavior without changing the data later supplied to the GRU.

The paper uses 263 observations for each of AAPL and MSFT. Its retained price field is the adjusted closing price, not the ordinary close. The implementation keeps dates alongside prices so that later windows and forecasts can remain aligned with their target dates.

What is fixed and what is chosen

The paper establishes several input facts: the two assets are modeled separately, each series contains 263 observations, and no missing values were reported. It also describes an upward trend, non-stationarity, strong autocorrelation, and no visible outliers.

Other details are not fully specified. The exact Yahoo Finance retrieval query, weekly aggregation rule, calendar handling, and source-data version are unavailable. Similarly, the paper does not state the decomposition method or period, the ADF regression and lag settings, or the autocorrelation lag range and interval construction. The generated code makes these choices explicit rather than presenting them as recovered paper settings.

Most importantly, diagnostics are descriptive. They do not remove outliers, replace values, difference the series for forecasting, or add decomposition components as GRU features.

Load local appendix-style records

The reproduction uses local CSV files instead of silently downloading from Yahoo Finance. This is an implementation decision that makes the input inspectable and avoids inventing an unspecified retrieval and resampling procedure. The expected layout is:

data/raw/
├── AAPL.csv
└── MSFT.csv

The loader accepts date and adjusted-price column aliases, but the normalization script writes the stable date and adjusted_close schema. A focused excerpt from src/stock_forecasting/io/appendix_loader.py shows the public loading contract:

from pathlib import Path

from stock_forecasting.data_types import PriceSeries
from stock_forecasting.exceptions import DataValidationError


def load_appendix_csv(path: Path, asset: str) -> PriceSeries:
    """Load one local appendix-style CSV as a validated price series.

    The local appendix file is deliberately used instead of downloading from Yahoo
    Finance: the paper identifies Yahoo Finance as provenance but does not specify
    the exact retrieval, calendar, or weekly aggregation procedure.  The loader
    therefore accepts already prepared records and preserves the adjusted-close
    field as the sole forecasting variable.
    """

load_appendix_csv reads one file and returns a PriceSeries containing dates, prices, and the canonical asset name. It checks optional asset labels so a file intended for AAPL cannot quietly contain MSFT rows. It sorts records chronologically and converts prices to floating-point values. It does not remove rows identified visually as unusual.

For the standard directory layout, use load_asset_records:

from pathlib import Path

from stock_forecasting.io.appendix_loader import load_asset_records

series = load_asset_records(Path("data/raw"), "AAPL")
print(series.asset)
print(series.dates.shape)
print(series.prices.shape)

The generated UnavailableYahooSource is deliberately not a network client. Its responsibility is to make an unsupported source choice fail clearly rather than download a different dataset:

class UnavailableYahooSource:
    """Explicit offline adapter for the paper's Yahoo Finance provenance."""

    def get_series(self, asset: str) -> PriceSeries:
        """Reject external retrieval and direct the caller to local data."""
        if not isinstance(asset, str) or asset not in _ALLOWED_ASSETS:
            raise DataValidationError(
                "asset must be either 'AAPL' or 'MSFT'; "
                f"received {asset!r}"
            )
        raise DataValidationError(
            f"No offline Yahoo Finance adapter is configured for {asset}. "
            "Provide the paper's local appendix-style records and load them "
            "with load_appendix_csv or load_asset_records."
        )

This failure is intentional. The paper names Yahoo Finance as provenance, but the supplied specification does not identify the exact query or weekly aggregation operation.

Validate the 263-observation contract

Loading a CSV is not the same as validating an experiment input. validate_price_series checks the paper's structural assumptions and returns a quality summary. Its default expected count is 263, although callers can use another count for isolated fixtures.

from stock_forecasting.data_validation import validate_price_series

quality = validate_price_series(series)
print(quality)

The validation boundary requires all of the following:

  • dates and prices each contain 263 entries;

  • dates are present, unique, and strictly increasing;

  • prices are one-dimensional, finite, and strictly positive;

  • the retained field is adjusted closing price;

  • no observations are removed as outliers.

The lower-level functions make the invariants visible. check_chronological_dates rejects missing, duplicate, or non-increasing dates. check_adjusted_prices rejects nonnumeric, non-finite, multidimensional, or non-positive values. summarize_quality reports counts, extrema, endpoints, chronology, and the outlier policy without modifying the series.

A concise end-to-end loading example is:

from pathlib import Path

from stock_forecasting.data_validation import validate_price_series
from stock_forecasting.io.appendix_loader import load_asset_records

DATA_DIR = Path("data/raw")
aapl = load_asset_records(DATA_DIR, "AAPL")
quality = validate_price_series(aapl)

print(aapl.asset)       # AAPL
print(aapl.dates.shape) # Expected shape: (263,)
print(aapl.prices.shape) # Expected shape: (263,)
print(quality)

The corresponding MSFT call should be made independently. Do not concatenate the two PriceSeries objects: asset isolation is a modeling requirement, not merely an organizational preference.

Prepare files without downloading data

When source records are supplied in an appendix-style directory, scripts/prepare_local_data.py validates and normalizes them into the expected raw-data layout. It requires local files and writes only date and adjusted_close.

python scripts/prepare_local_data.py \
    --input-dir appendix \
    --output-dir data/raw

prepare_asset_file loads one asset, invokes validate_price_series with expected_count=263, and writes a normalized CSV. It does not fill missing values, infer missing weeks, or correct outliers. Consequently, a short or malformed input fails before it can enter the modeling pipeline.

Descriptive statistics and reference comparisons

The paper's descriptive table reports count, mean, standard deviation, minimum, quartiles, median, and maximum for each 263-observation series. The generated compute_descriptive_statistics function returns those fields as a pandas Series:

from stock_forecasting.eda.descriptive import (
    compare_with_table_2,
    compute_descriptive_statistics,
)

stats = compute_descriptive_statistics(aapl)
print(stats)

# Generated statistic minus the paper's Table 2 reference value.
differences = compare_with_table_2(stats, aapl.asset)
print(differences)

The stored Table 2 values are paper references, not assertions that an unverified local file matches the paper's source exactly. Differences may reflect source-data versions, weekly aggregation, or numeric details. The helper reports signed differences and does not label them as a pass or fail.

Plot the distribution and chronology

The plotting functions in src/stock_forecasting/eda/plots.py return Matplotlib axes and read the source arrays without mutating them. The histogram describes the price distribution, the boxplot supports visual outlier screening, and the chronological plot reveals trend and fluctuations.

import matplotlib.pyplot as plt

from stock_forecasting.eda.plots import (
    plot_boxplot,
    plot_price_distribution,
    plot_price_series,
    save_figure,
)

figure, axes = plt.subplots(3, 1, figsize=(10, 12), constrained_layout=True)
plot_price_distribution(aapl, ax=axes[0])
plot_boxplot(aapl, ax=axes[1])
plot_price_series(aapl, ax=axes[2])
save_figure(figure, Path("artifacts/diagnostics/AAPL_overview.png"))
plt.close(figure)

A boxplot point is evidence for inspection only. The paper says that no visible outliers were found, but it gives no formal threshold or correction rule. Therefore, this implementation retains every validated observation even if a different local file produces a visually unusual point.

Decomposition: diagnostic components only

The paper asks for decomposition into trend, seasonal, and residual components, but it does not specify how. The generated wrapper requires a DecompositionConfig, making the method and period visible:

from stock_forecasting.eda.decomposition import (
    DecompositionConfig,
    decompose_price_series,
)

# Explicit implementation decision for this diagnostic run.
config = DecompositionConfig(method="additive", period=52)
components = decompose_price_series(aapl, config)

for name, component in components.items():
    print(name, component.shape, component.index.equals(aapl.dates))

Here, additive decomposition and a 52-observation period are implementation decisions for a weekly series. They are not parameters recovered from the paper. The returned dictionary contains trend, seasonal, and residual pandas Series, each aligned to the original 263 dates. Boundary component values may be missing because the decomposition needs neighboring observations; they are retained rather than imputed.

The components are not passed into the GRU. The forecasting input remains the single adjusted-price feature. This separation prevents an exploratory choice from silently changing the proposed model.

ADF stationarity diagnostic

The paper reports raw-price non-stationarity and gives approximate ADF p-values of 0.4594 for AAPL and 0.9229 for MSFT. Those values depend on test settings, and the supplied paper text does not specify the deterministic regression term or lag-selection policy. The generated run_adf_test therefore requires both:

from stock_forecasting.eda.stationarity import interpret_adf, run_adf_test

# Explicit implementation choices, not recovered paper settings.
aapl_adf = run_adf_test(aapl, regression="c", autolag="AIC")
print(aapl_adf)
print(interpret_adf(aapl_adf, alpha=0.05))

ADFReport contains the test statistic, p-value, and critical values. interpret_adf uses the paper's stated significance threshold of 0.05: a p-value below that threshold is reported as rejection of the unit-root null, while a p-value at or above it is reported as failure to reject.

Failure to reject is a diagnostic conclusion under one test specification, not proof about every future path. Likewise, the presence of non-stationarity does not mean that a GRU mathematically removes non-stationarity or remains valid under distribution shift. The ADF call does not difference or otherwise alter the price series used later.

Repeat the explicitly configured diagnostic for MSFT:

msft = load_asset_records(Path("data/raw"), "MSFT")
msft_adf = run_adf_test(msft, regression="c", autolag="AIC")
print(interpret_adf(msft_adf, alpha=0.05))

Autocorrelation diagnostic

Autocorrelation measures how values in the ordered series relate to earlier values at selected lags. The paper describes strong autocorrelation but does not specify the lag range or confidence-interval construction. The generated implementation therefore accepts an inclusive max_lag and returns only lag-aligned values:

from stock_forecasting.eda.autocorrelation import compute_autocorrelation

aapl_acf = compute_autocorrelation(aapl, max_lag=40)
print(aapl_acf.lags.shape)   # (41,)
print(aapl_acf.values.shape) # (41,)
print(aapl_acf.values[0])     # 1.0 by contract

Using lags 0 through 40 is an implementation decision. AutocorrelationReport requires contiguous lags beginning at zero and equal-length lags and values arrays. The report does not include confidence intervals because no interval method was supplied.

The diagnostic can be plotted without adding those values to the forecasting features:

import matplotlib.pyplot as plt

figure, axis = plt.subplots(figsize=(10, 4))
axis.stem(aapl_acf.lags, aapl_acf.values)
axis.set_title("AAPL adjusted-price autocorrelation")
axis.set_xlabel("Lag")
axis.set_ylabel("Autocorrelation")
save_figure(figure, Path("artifacts/diagnostics/AAPL_acf.png"))
plt.close(figure)

Run both assets from the command line

The diagnostic script applies the same explicit parameter choices independently to AAPL and MSFT and writes JSON reports, decomposition CSV files, and figures:

python scripts/run_diagnostics.py \
    --data-dir data/raw \
    --output-dir artifacts/diagnostics

Its defaults record an additive decomposition with period 52, an ADF constant term with AIC autolag, significance threshold 0.05, and autocorrelation through lag 40. Because several of these values are not paper facts, the script stores them as implementation metadata in each asset's diagnostic report.

What this stage establishes

After this stage, the intended state is a pair of independently validated PriceSeries objects and non-mutating diagnostic artifacts. The qualitative expectations from the paper are an upward trend, non-stationary raw prices, substantial autocorrelation, and no visible outliers. Those are interpretive reference points, not execution results claimed here.

No code execution, tests, static verification, semantic code verification, tutorial verification, or final quality review was performed under the supplied run policy. The next stage can therefore explain the planned transformation without implying that these diagnostics have already produced verified outputs.

The following section turns each unchanged 263-value sequence into supervised examples: three preceding adjusted prices become one input sequence, and the following price becomes its target. That transformation remains separate from the diagnostic plots and tests described here.

From a Price Sequence to Supervised Windows

How can a plain chronological price list become input that a recurrent neural network can learn from? The paper uses a three-period sliding window: take three consecutive adjusted prices, use them as one short sequence, and assign the following adjusted price as the target. Then move forward by one observation and repeat.

This transformation is performed separately for each asset and before any train, test, or validation split. AAPL and MSFT therefore produce independent WindowedDataset objects; their prices are never combined into one multivariate input.

The input and target contract

The generated code represents one raw asset as a PriceSeries. Its dates and prices fields are aligned one-to-one, with 263 chronological observations and positive adjusted prices. The create_windowed_supervised_dataset function then returns a WindowedDataset with three coordinated fields:

  • inputs: recurrent input windows with shape (260, 3, 1);

  • targets: one scalar next-price target per window, with shape (260, 1); and

  • target_dates: the dates associated with those target prices, with length 260.

The three dimensions of inputs mean (samples, time steps, features). There are 260 samples, each sequence contains 3 time steps, and each time step contains 1 feature: the adjusted price. The singleton feature dimension is important because Keras recurrent layers expect a feature axis even for a univariate series.

The paper supplies the three-period window and 263 raw observations. The count of supervised samples follows from the implementation: the first three observations form an input but do not yet have a preceding window of their own, so each subsequent raw observation can serve as one target. Consequently, 263 raw observations produce 260 samples.

Building the windows

The public function accepts a validated PriceSeries and defaults to window_length=3:

from stock_forecasting.supervised.windowing import create_windowed_supervised_dataset

windowed = create_windowed_supervised_dataset(series, window_length=3)

print(windowed.inputs.shape)       # (260, 3, 1)
print(windowed.targets.shape)      # (260, 1)
print(windowed.target_dates.shape) # (260,)

Inside create_windowed_supervised_dataset, the implementation constructs each input from a consecutive slice and adds the feature axis. The following excerpt is the central transformation copied from src/stock_forecasting/supervised/windowing.py:

    # Three preceding adjusted prices predict the next adjusted price.
    inputs = np.stack(
        [prices[index : index + int(window_length)] for index in range(sample_count)],
        axis=0,
    )[..., np.newaxis]
    targets = prices[int(window_length) :].reshape(-1, 1)
    target_dates = dates[int(window_length) :]

For sample index 0, the input slice contains raw prices at positions 0, 1, and 2. The target slice begins at position 3, so its first value is the price immediately after that input. The [..., np.newaxis] operation changes a two-dimensional collection of windows into the rank-three recurrent shape (samples, 3, 1). The target reshape standardizes one scalar per sample as (samples, 1), matching the intended scalar model output.

A worked example with inspectable values

A monotonic synthetic series makes the boundary behavior easy to see without depending on external data. This is an illustration only, not a replacement for the paper's AAPL or MSFT records.

import numpy as np
import pandas as pd

from stock_forecasting.data_types import PriceSeries
from stock_forecasting.supervised.windowing import (
    create_windowed_supervised_dataset,
)

_RAW_COUNT = 263

raw_dates = pd.date_range("2020-01-03", periods=_RAW_COUNT, freq="W-FRI")
raw_prices = np.arange(1, _RAW_COUNT + 1, dtype=np.float64)

series = PriceSeries(
    dates=raw_dates,
    prices=raw_prices,
    asset="AAPL",
)

windowed = create_windowed_supervised_dataset(series)

The first sample contains prices 1, 2, and 3. Its target is price 4, and its target date is the fourth date in the raw series:

np.testing.assert_allclose(
    windowed.inputs[0, :, 0],
    np.array([1.0, 2.0, 3.0]),
)
np.testing.assert_allclose(windowed.targets[0], np.array([4.0]))
assert windowed.target_dates[0] == raw_dates[3]

The final sample contains the last three prices before the final raw observation. Its target is 263, and its date is the final raw date:

np.testing.assert_allclose(
    windowed.inputs[-1, :, 0],
    np.array([260.0, 261.0, 262.0]),
)
np.testing.assert_allclose(windowed.targets[-1], np.array([263.0]))
assert windowed.target_dates[-1] == raw_dates[-1]

This boundary example makes the no-future-leakage invariant visible: the target never appears inside its own input window. It also explains why target dates, rather than the final input dates, are retained. A forecast plotted against target_dates is compared with the observation it was intended to predict.

Structural validation and failure cases

WindowedDataset validates the recurrent and target shapes, matching sample counts, finite values, positive prices, and chronological target dates. The companion validate_windowed_dataset function checks the expected relationship between source length, window length, and sample count. It rejects a non-integer or non-three window length in this reproduction, because the generated container deliberately implements the paper-specific (n, 3, 1) contract.

The function also rejects common data problems before a model sees them. It raises a domain-specific DataValidationError when the input is not a PriceSeries, when the series does not contain exactly 263 prices and dates, when values are non-finite or non-positive, or when the constructed result violates the expected shapes. These checks protect both temporal alignment and the downstream Keras input contract.

The supplied test file expresses the important boundary checks with deterministic prices:

def test_recurrent_shape_is_three_by_one() -> None:
    """Verify the recurrent input and scalar-target tensor contracts."""
    dataset = create_windowed_supervised_dataset(_synthetic_price_series())

    assert dataset.inputs.shape == (_EXPECTED_SAMPLE_COUNT, _WINDOW_LENGTH, 1)
    assert dataset.targets.shape == (_EXPECTED_SAMPLE_COUNT, 1)
    assert dataset.target_dates.shape == (_EXPECTED_SAMPLE_COUNT,)
    assert dataset.inputs.dtype == np.float64
    assert dataset.targets.dtype == np.float64

The other generated tests check that the sample count is exactly 260 and that the first and last windows use the correct preceding prices, targets, and dates. Those tests are planned verification artifacts; under the current run policy, they were not executed and no passing result is claimed.

Why windowing comes before splitting

The paper-consistent dimensional interpretation is to construct all 260 one-step samples first and then partition those samples chronologically into 156 training, 52 testing, and 52 validation samples. Splitting the raw 263 observations first would describe a different boundary procedure and could change which windows are available at each partition boundary.

After windowing, each sample already has an input, a target, and a target date. The later splitting stage can therefore slice all three arrays together while preserving order. This keeps the temporal direction clear: no future target is included in an earlier sample, and no random shuffle is needed to create the supervised dataset.

Advanced detail: target shape conventions

Some forecasting libraries store scalar targets as a one-dimensional array with shape (n,). That representation can be valid, but this implementation intentionally standardizes targets as (n, 1). The choice makes the target contract match a model whose final output is one value per sample and helps prevent accidental broadcasting during training and evaluation. It is an implementation convention, not an additional feature or a change to the paper's one-step forecasting task.

No display equation is included here: the supplied paper context contains no canonical equation records. The index-level procedure, array shapes, and code slices above provide the supported mathematical meaning without reconstructing an equation from memory.

Chronological Splitting and Training-Only Scaling

How do we prevent a forecasting experiment from learning about the future before evaluation? The answer is procedural: create all supervised windows first, place them in chronological order, divide those windows into contiguous partitions, and learn scaling parameters from the training partition only. Later testing and validation values may be transformed with that state, but they must not determine it.

This section uses the paper-consistent dimensional interpretation. Each asset begins with 263 weekly adjusted closing prices. A three-period one-step window produces 260 supervised samples. Those samples are then divided into 156 training samples, 52 testing samples, and 52 validation samples. AAPL and MSFT go through this process independently.

What the paper specifies—and what it leaves open

The paper specifies chronological partitioning and training-only MinMax normalization. This protects the temporal direction of the experiment: earlier samples are used before later samples. It does not specify whether the scaler was fitted to the raw one-dimensional training-price series, to flattened training windows, or to training inputs and targets together.

That missing detail matters because a MinMax scaler stores data-dependent bounds. Different fitting populations can produce different normalized values, training losses, and eventually price-unit metrics. The implementation therefore exposes two named ScalerConvention choices. This is an implementation decision, not a recovered detail from the paper.

The paper also keeps testing and validation conceptually distinct, although its reported metrics are described as validation evaluation. Because the exact use of the testing partition is unclear, the reproduction retains both partitions and requires the caller to choose explicitly later.

Partition the windowed samples

split_windowed_dataset receives a WindowedDataset and a SplitConfig. The dataset contains recurrent inputs, scalar targets, and target dates. The configuration contains the three requested partition sizes. The function slices all three fields at the same boundaries and returns a DatasetSplits object with fields such as train_inputs, test_inputs, and validation_inputs.

The important algorithm is short and deliberately does not shuffle:

train_end = config.train_size
test_end = train_end + config.test_size

train_inputs = dataset.inputs[:train_end]
train_targets = dataset.targets[:train_end]
train_dates = dataset.target_dates[:train_end]

test_inputs = dataset.inputs[train_end:test_end]
test_targets = dataset.targets[train_end:test_end]
test_dates = dataset.target_dates[train_end:test_end]

validation_inputs = dataset.inputs[test_end:]
validation_targets = dataset.targets[test_end:]
validation_dates = dataset.target_dates[test_end:]

With the default configuration, train_end is 156 and test_end is 208. Therefore the training indices are 0 through 155, testing indices are 156 through 207, and validation indices are 208 through 259. Every windowed sample is consumed once.

The configuration is supplied by get_default_split_config:

def get_default_split_config() -> SplitConfig:
    """Return the paper-consistent chronological 156/52/52 split.

    The split is a derived dimensional correction: 263 raw observations minus
    a three-period window produce 260 supervised samples.
    """
    return SplitConfig(train_size=156, test_size=52, validation_size=52)

The SplitConfig constructor rejects sizes that do not sum to 260. That check encodes the derived dimensional contract rather than treating 263 raw observations as 263 supervised examples.

Worked example: inspect the three boundaries

The following example uses the monotonic synthetic WindowedDataset introduced in the windowing discussion. It is useful because the sample indices are easy to inspect; it is not AAPL or MSFT data.

split_config = get_default_split_config()
splits = split_windowed_dataset(windowed, split_config)

assert splits.train_inputs.shape == (156, 3, 1)
assert splits.train_targets.shape == (156, 1)
assert len(splits.train_dates) == 156

assert splits.test_inputs.shape == (52, 3, 1)
assert splits.test_targets.shape == (52, 1)
assert len(splits.test_dates) == 52

assert splits.validation_inputs.shape == (52, 3, 1)
assert splits.validation_targets.shape == (52, 1)
assert len(splits.validation_dates) == 52

Notice the shape invariants. Each input remains rank three: sample count, sequence length 3, and one feature. Each target remains a rank-two scalar batch with shape (n, 1). Dates have one entry per target, not one entry per input timestep.

validate_split_boundaries checks these shapes and also checks finite positive values, chronological order within each partition, and non-overlap between adjacent partitions. This function is a boundary guard: it does not repair an incorrectly ordered dataset or infer a missing split.

Random splitting would undermine the intended experiment. It could place later price regimes in the training partition and earlier regimes in validation, making the held-out data no longer a clean later period. The generated splitter therefore performs contiguous slicing and never calls a random shuffling operation.

Fit the scaler after splitting

Scaling occurs after the chronological split so that the fitting function receives training arrays only. The generated API is:

scaler = fit_training_scaler(
    train_inputs=splits.train_inputs,
    train_targets=splits.train_targets,
    convention=ScalerConvention.RAW_TRAINING_PRICES,
)

fit_training_scaler returns a one-feature scikit-learn MinMaxScaler. It validates that inputs have shape (n, 3, 1) and that targets have shape (n,) or (n, 1). In this reproduction, the selected convention is recorded through the enum value, so experiment metadata can state which policy was requested.

There is an important implementation limitation to understand precisely. The current helper does not receive the original raw training series separately. Consequently, its RAW_TRAINING_PRICES branch fits from the price values available in the training inputs and targets. The FLATTENED_TRAINING_WINDOWS branch currently uses the same supplied training values after flattening. The two names preserve the unresolved protocol choice in the public API, but they do not yet implement two different fitting populations. This should not be mistaken for evidence that the paper used either convention.

The fitting function deliberately receives no testing or validation arrays. Applying a fitted scaler later is allowed; refitting it later is not.

Transform while preserving shapes

Use the same scaler for every partition belonging to the same asset:

scaled_train_inputs = transform_windows(scaler, splits.train_inputs)
scaled_test_inputs = transform_windows(scaler, splits.test_inputs)
scaled_validation_inputs = transform_windows(scaler, splits.validation_inputs)

scaled_train_targets = transform_targets(scaler, splits.train_targets)
scaled_test_targets = transform_targets(scaler, splits.test_targets)
scaled_validation_targets = transform_targets(scaler, splits.validation_targets)

assert scaled_train_inputs.shape == (156, 3, 1)
assert scaled_test_inputs.shape == (52, 3, 1)
assert scaled_validation_inputs.shape == (52, 3, 1)
assert scaled_train_targets.shape == (156, 1)
assert scaled_test_targets.shape == (52, 1)
assert scaled_validation_targets.shape == (52, 1)

transform_windows flattens the final one-feature view temporarily for the scaler, then reshapes the result back to (n, 3, 1). transform_targets similarly preserves the target's scalar-batch convention. Neither function refits the scaler, and neither changes sample order or date arrays.

AAPL and MSFT need separate scaler instances. Their prices have different levels and distributions, and the paper models the assets in isolation. Reusing one scaler across both series would introduce cross-asset information and would not match the stated univariate design.

Inverse transformation before evaluation

The GRU is trained on normalized values, so its predictions are initially in normalized target units. Before calculating price-unit metrics, convert both predictions and actual targets back with the same training-fitted scaler:

restored_training_targets = inverse_targets(scaler, scaled_train_targets)

The inverse operation preserves the input rank. For a held-out validation batch, an array with shape (52, 1) returns to the same shape and represents adjusted-price values again. The evaluation stage then flattens or otherwise validates the arrays according to its metric contract, aligns them with the 52 validation target dates, and computes metrics in original units.

This ordering is essential. Evaluating normalized values would produce errors whose units and scale depend on preprocessing rather than on the stock price itself. The intended sequence is therefore: fit on training values, transform all model inputs and targets, predict in normalized space, inverse-transform predictions and actuals, then calculate price-unit metrics.

Planned tests and verification status

The generated test file contains focused checks for the split and scaler invariants, including the expected 156/52/52 shapes, date contiguity, unchanged scaler state after transforming validation values, shape preservation, and inverse transformation. These tests are planned artifacts, not evidence of completed verification. Under the supplied run policy, code execution, testing, local static verification, semantic code verification, and tutorial verification were disabled. No test result is claimed here.

Reproduction checklist

For this preprocessing stage, a reviewable run should record the following:

  1. The input asset and its 263 chronological adjusted prices.

  2. The three-period windowing step that produced 260 samples.

  3. The SplitConfig values 156, 52, and 52.

  4. The fact that testing remains separate from validation.

  5. The selected ScalerConvention value.

  6. The scaler's training-only fitting population.

  7. The recurrent and target shapes before and after transformation.

  8. The scaler instance retained for inverse transformation.

  9. The explicit held-out subset selected for later evaluation.

The result is leakage-aware preprocessing, but not a guarantee of accurate forecasting. Scaling choices can affect the numerical outcome even when the GRU architecture and training settings remain unchanged. Exact reproduction still depends on unresolved source-data, preprocessing, and evaluation details.

Asset-Specific GRU Architectures

How does the implementation turn a three-price sequence into one next-price forecast? A gated recurrent unit, or GRU, reads the input steps in order and maintains an internal representation of the sequence. Here, each sample contains three prior adjusted prices for one asset, so the model receives a tensor with shape (batch, 3, 1) and returns one normalized scalar for every sample, with shape (batch, 1).

The paper uses separate univariate models. The AAPL model never receives MSFT values, and the MSFT model never receives AAPL values. This isolation is important because the paper evaluates asset-specific architectures rather than a shared multivariate network.

Table 1 configurations versus implementation choices

The reported final configurations are paper facts. AAPL uses three GRU layers with widths (240, 16, 16), while MSFT uses two layers with widths (224, 48). Both configurations use dropout 0.0, learning rate 0.001, MSE loss, batch size 8, a maximum of 100 epochs, and patience 7.

The generated AssetModelConfig stores these values together with the asset identifier:

@dataclass(frozen=True)
class AssetModelConfig:
    """Immutable configuration for one isolated asset GRU model.

    ``units`` contains the number of units in each stacked GRU layer.  The
    paper-selected values are AAPL ``(240, 16, 16)`` and MSFT ``(224, 48)``.
    Activation functions, initializers, return-sequence behavior, and the
    output-layer details are not specified by the supplied paper text and are
    intentionally not represented as paper facts here.
    """

    asset: str
    units: tuple[int, ...]
    dropout: float
    learning_rate: float
    loss_name: str
    batch_size: int
    max_epochs: int
    patience: int

The validation in AssetModelConfig rejects unsupported assets, empty or non-positive unit tuples, invalid dropout rates, non-positive learning rates, unsupported losses, and invalid training limits. These checks protect the configuration boundary; they do not add new model behavior described by the paper.

The factory returns the reported settings directly. It does not pretend to recover the original Hyperband search space or random seed:

def get_final_model_config(asset: str) -> AssetModelConfig:
    """Return the supplied Table 1 final configuration for ``asset``.

    The layer widths, zero dropout, learning rate, MSE loss, batch size,
    epoch limit, and patience are paper facts.  They are returned directly
    rather than inferred from an unavailable Hyperband search space.
    """
    if asset == "AAPL":
        units = (240, 16, 16)
    elif asset == "MSFT":
        units = (224, 48)
    else:
        raise ValueError(f"unsupported asset {asset!r}; expected AAPL or MSFT")

    return AssetModelConfig(
        asset=asset,
        units=units,
        dropout=0.0,
        learning_rate=0.001,
        loss_name="mse",
        batch_size=8,
        max_epochs=100,
        patience=7,
    )

Connecting recurrent layers

A stacked GRU passes a sequence from each intermediate recurrent layer to the next one. In the generated builder, every GRU except the last uses return_sequences=True. The terminal GRU returns its final representation, which is passed to Dense(1) to produce one scalar output.

These settings are implementation decisions, not additional paper facts. The supplied paper text does not specify return-sequence behavior, activation functions, recurrent activations, initializers, or the terminal output-layer type. The generated implementation makes those choices explicit so that the model has a definite tensor contract:

for index, width in enumerate(units):
    is_terminal = index == len(units) - 1
    representation = keras.layers.GRU(
        width,
        activation="tanh",
        recurrent_activation="sigmoid",
        dropout=float(dropout),
        return_sequences=not is_terminal,
        name=f"gru_{index + 1}",
    )(representation)

outputs = keras.layers.Dense(1, name="next_adjusted_close")(representation)
return keras.Model(inputs=inputs, outputs=outputs, name="stacked_gru_forecaster")

The helper validate_gru_architecture checks that the unit tuple is non-empty and contains positive integers, that dropout lies in the interval [0, 1), and that the input shape is exactly (3, 1). Its failure cases are deliberate: a different sequence length or feature count would no longer represent the paper's three prior prices and one univariate feature.

No GRU gate equations are displayed here. The supplied context contains no canonical equation records, so reconstructing equations from general GRU knowledge would incorrectly present an unavailable formula as part of the paper specification.

Building an isolated asset model

build_asset_gru_model accepts one validated AssetModelConfig and an input shape. It delegates layer construction to build_gru_stack, then checks the resulting model's input and output shapes. TensorFlow is imported lazily by the lower-level builder, allowing data and preprocessing components to remain conceptually separate from the optional neural-network runtime.

from stock_forecasting.config import get_final_model_config
from stock_forecasting.models.gru_model import (
    build_asset_gru_model,
    describe_model_contract,
)

# Table 1 paper configuration: AAPL uses three GRU layers.
aapl_config = get_final_model_config("AAPL")
aapl_model = build_asset_gru_model(aapl_config, input_shape=(3, 1))
print(describe_model_contract(aapl_model))

The corresponding MSFT model is constructed independently:

from stock_forecasting.config import get_final_model_config
from stock_forecasting.models.gru_model import build_asset_gru_model

# Table 1 paper configuration: MSFT uses two GRU layers.
msft_config = get_final_model_config("MSFT")
msft_model = build_asset_gru_model(msft_config, input_shape=(3, 1))

describe_model_contract reports the model name, input and output shapes, recurrent layer widths, dropout values, and return_sequences settings. It rejects models without inspectable shapes, without GRU layers, with the wrong scalar output, or with an invalid sequence-return arrangement. The model-building path also raises a domain-specific ConfigurationError if TensorFlow is unavailable or if the constructed model violates the expected shape contract.

Worked example: compare the two architectures

The following inspection workflow expresses the intended comparison without training either model:

from stock_forecasting.config import get_final_model_config
from stock_forecasting.models.gru_model import (
    build_asset_gru_model,
    describe_model_contract,
)

for asset in ("AAPL", "MSFT"):
    config = get_final_model_config(asset)
    model = build_asset_gru_model(config, input_shape=(3, 1))
    contract = describe_model_contract(model)

    print(asset, config.units)
    print(contract["input_shape"], contract["output_shape"])

The expected structural interpretation is that AAPL has three recurrent layers with widths 240, 16, and 16, while MSFT has two with widths 224 and 48. Both accept (None, 3, 1) at the model boundary and emit (None, 1), where None represents a variable batch size. The configurations also carry training settings such as learning rate, loss name, batch size, maximum epochs, and patience, although those settings are applied later during compilation and training.

The generated contract tests check these structural invariants without asserting forecast quality:

def test_assets_are_not_combined() -> None:
    """AAPL and MSFT are built as independent models with no shared layers."""
    aapl_model = build_asset_gru_model(get_final_model_config("AAPL"))
    msft_model = build_asset_gru_model(get_final_model_config("MSFT"))

    assert aapl_model is not msft_model
    assert _layer_units(aapl_model) == (240, 16, 16)
    assert _layer_units(msft_model) == (224, 48)

    aapl_layer_ids = {id(layer) for layer in _gru_layers(aapl_model)}
    msft_layer_ids = {id(layer) for layer in _gru_layers(msft_model)}
    assert aapl_layer_ids.isdisjoint(msft_layer_ids)
    assert len(_gru_layers(aapl_model)) != len(_gru_layers(msft_model))

This test describes the intended invariant: separate models have separate layer objects and preserve their asset-specific widths. It does not establish that training succeeds, that predictions are accurate, or that the paper's reported metrics are reproduced.

Normalized outputs and the next stage

The model operates on scaled inputs and produces a scaled next-price prediction. The output is therefore not yet an adjusted closing price in the original price units. The later evaluation stage must use the same asset-specific training-fitted scaler to inverse-transform predictions and targets before computing price-unit metrics.

The architecture alone cannot establish the paper's reported MSE, MAE, MAPE, or forecast accuracy. Those values also depend on the local data snapshot, scaling convention, training randomness, callback behavior, evaluation subset, and other unspecified protocol details. Under the supplied run policy, this model construction and its tests were not executed, and neither static verification nor semantic code verification was completed.

Hyperband Tuning, Adam Training, and Best Checkpoints

How should we choose and train a GRU without allowing later observations to influence the model? The workflow has two distinct stages. First, optional Hyperband tuning compares candidate asset-specific configurations using validation loss. Second, the selected configuration is trained with Adam while a separate chronological validation partition controls early stopping and checkpoint selection.

The paper describes this procedure, but it does not provide enough tuner metadata to reconstruct the original search exactly. The generated implementation therefore supports both a direct reproduction path using the reported Table 1 configurations and an optional, explicitly documented local Hyperband search.

Paper facts and implementation decisions

The paper reports separate searches and models for AAPL and MSFT. The final configurations are:

  • AAPL: GRU widths (240, 16, 16);

  • MSFT: GRU widths (224, 48);

  • dropout 0.0 for both assets;

  • Adam learning rate 0.001;

  • MSE loss;

  • batch size 8;

  • maximum 100 epochs; and

  • early-stopping patience 7.

The paper also identifies validation loss as the Hyperband selection objective. Training uses the 156-sample training partition, while the separate 52-sample validation partition is supplied for monitoring. The 52-sample testing partition is not passed to model fitting or callback monitoring.

Several details remain unspecified: the exact Hyperband search space, bracket and iteration settings, random seed, tuner stopping criteria, Keras validation arrangement, callback filename, monitor mode, and whether best weights were restored automatically. The generated code makes these choices explicit rather than presenting them as recovered paper facts. In particular, it uses shuffle=False, minimizes val_loss, restores best weights, and saves weights to a .weights.h5 file.

A resource-aware search, without claiming exact tuner recovery

Hyperband is a resource-aware search strategy. It begins with multiple candidate configurations and allocates more training resources to candidates that appear promising. In this reproduction, a candidate may vary in recurrent depth, unit counts, dropout, learning rate, and loss name. The search is run separately for each asset, so AAPL and MSFT observations never enter the same tuner.

The search-space container documents the local choices:

@dataclass(frozen=True)
class TuningSearchSpace:
    """Candidate values used by the optional Hyperband reproduction.

    The paper reports that Hyperband searched layer depth, recurrent widths,
    dropout, learning rate, and loss, but it does not provide the original
    candidate ranges.  Consequently, the values returned by
    :func:`default_search_space` are explicit implementation decisions rather
    than recovered paper facts.
    """

    layer_options: tuple[int, ...]
    unit_options: tuple[int, ...]
    dropout_options: tuple[float, ...]
    learning_rates: tuple[float, ...]
    loss_options: tuple[str, ...]

TuningSearchSpace contains finite candidate values and validate_search_space rejects malformed options such as nonpositive widths, invalid dropout values, duplicate entries, or unsupported losses. The generated default_search_space includes the reported widths and learning rate so the Table 1 configurations are representable, but its ranges are implementation decisions.

The search function receives a DatasetSplits object whose scaled training inputs have shape (156, 3, 1) and targets have shape (156, 1). Its validation inputs and targets have shapes (52, 3, 1) and (52, 1). It passes only the training and validation arrays to Keras Tuner:

tuner.search(
    train_data.train_inputs,
    train_data.train_targets,
    validation_data=(
        train_data.validation_inputs,
        train_data.validation_targets,
    ),
    epochs=100,
    batch_size=8,
    shuffle=False,
    verbose=0,
)

The objective is explicitly val_loss with direction "min". The testing partition remains distinct. If TensorFlow or Keras Tuner is unavailable, the function raises a configuration error rather than silently substituting another algorithm. It also rejects invalid shapes, wrong sample counts, non-finite values, and searches that produce no finite validation score.

The paper's final architecture can be selected directly when the original tuner metadata is unavailable:

from stock_forecasting.tuning.hyperband import select_reported_configuration

reported_aapl_config = select_reported_configuration("AAPL")
reported_msft_config = select_reported_configuration("MSFT")

select_reported_configuration returns the Table 1 configuration. It does not rerun Hyperband and does not claim that the generated tuner recovered the paper's search. This direct path is the appropriate default for the requested reproduction target.

Normalized-space MSE and model compilation

During training, predictions and targets remain on the scaler's normalized price scale. The model returns one scalar prediction per sample, so predictions and targets should use compatible scalar-batch shapes, preferably (batch, 1). The models.losses module validates this contract and rejects unintended broadcasting or mismatched batch lengths.

The paper selects MSE for both final asset models. No canonical loss equation was supplied in the paper context, so this tutorial does not reconstruct or display one. Instead, resolve_loss("mse") delegates the numerical loss behavior to the configured Keras/TensorFlow version. This keeps the framework's reduction behavior in one place and avoids presenting an invented formula as a paper equation.

Compilation constructs Adam with the configured learning rate and attaches the resolved loss:

# The paper-selected MSE loss operates on normalized scalar predictions
# and targets; inverse transformation is intentionally deferred to
# evaluation.
optimizer = Adam(learning_rate=config.learning_rate)
loss = resolve_loss(config.loss_name)

try:
    model.compile(optimizer=optimizer, loss=loss)
except (TypeError, ValueError) as exc:
    raise ConfigurationError(
        f"Keras rejected the model compilation configuration: {exc}"
    ) from exc

compile_gru_model accepts an uncompiled Keras-compatible model and an AssetModelConfig, then returns the same model after compilation. It validates the learning rate and supported loss before importing TensorFlow. A missing TensorFlow installation or a model without a callable compile method is treated as a clear configuration failure.

The validation loss values reported by the paper are not explicitly labeled as normalized-space or original-price-unit values. The generated code treats training and validation loss as model-space quantities and reserves price-unit MSE, MAE, and MAPE for the inverse-transformed evaluation stage. This is an implementation interpretation, not a confirmed explanation of the paper's reported validation-loss units.

Training with chronological validation

Once a model configuration is selected, train_gru_with_callbacks compiles the model, builds the callbacks, and calls Keras fit. The important data-flow rule is that only the training partition updates weights:

history = compiled_model.fit(
    splits.train_inputs,
    splits.train_targets,
    validation_data=(splits.validation_inputs, splits.validation_targets),
    epochs=config.max_epochs,
    batch_size=config.batch_size,
    shuffle=False,
    callbacks=callbacks,
    verbose=0,
)

Here, splits.train_inputs has shape (156, 3, 1) and splits.train_targets has shape (156, 1). The validation arrays have corresponding shapes (52, 3, 1) and (52, 1). shuffle=False is an implementation decision that preserves chronological sample order; the extracted paper does not explicitly state its shuffle setting.

The configuration supplies the paper's batch size, epoch limit, and patience. The helper checks these shapes and counts before training, rejects non-finite arrays, and verifies that training and validation dates match their expected partition sizes. It never passes test_inputs or test_targets to fit, so the testing data cannot influence parameter updates or callback selection.

Early stopping and best checkpoints

Early stopping answers a practical question: when validation loss stops improving, should training continue merely because the maximum epoch count has not been reached? The generated callback policy monitors val_loss, minimizes it, tolerates seven non-improving epochs, and restores the best in-memory weights. A matching ModelCheckpoint saves the best weights locally.

# The paper specifies validation-loss monitoring and best-state retention;
# mode='min' and restore_best_weights=True make those choices explicit.
early_stopping = keras.callbacks.EarlyStopping(
    monitor=_VALIDATION_MONITOR,
    mode="min",
    patience=config.patience,
    restore_best_weights=True,
    verbose=0,
)
checkpoint = keras.callbacks.ModelCheckpoint(
    filepath=str(checkpoint_path),
    monitor=_VALIDATION_MONITOR,
    mode="min",
    save_best_only=True,
    save_weights_only=True,
    verbose=0,
)
return [early_stopping, checkpoint]

The paper specifies patience 7 and checkpointing, but not the monitor mode, restoration flag, or file format. The generated implementation chooses mode="min" because lower validation loss is preferred, uses restore_best_weights=True, and requires a checkpoint path ending in .weights.h5. build_training_callbacks creates the parent directory and raises an error for an invalid path or missing TensorFlow.

After fitting, train_gru_with_callbacks requires the checkpoint file to exist and reloads it. The returned TrainingResult contains the model, training history, and checkpoint path. Thus, forecasting uses the best validation-loss state rather than assuming the final epoch is best. This is a runtime contract; no training or checkpoint creation was performed for this article under the run policy.

Worked example: direct reproduction versus optional tuning

For the direct Table 1 path, keep tuning disabled. The pipeline then obtains the reported asset configuration through get_final_model_config, builds the corresponding GRU, trains it with the callback policy, and retains separate artifacts under the selected asset directory:

from pathlib import Path

from stock_forecasting.pipeline import run_asset_pipeline

result = run_asset_pipeline(
    asset="AAPL",
    data_dir=Path("data/raw"),
    output_dir=Path("artifacts"),
    evaluation_subset="validation",
    tune=False,
)

This call is procedural rather than a claimed result. The pipeline loads local AAPL records, preserves the 156/52/52 split, fits the scaler on training data, constructs the reported AAPL configuration, trains with validation monitoring, and evaluates the explicitly selected validation subset. The returned AssetRunResult retains the raw PriceSeries, windowed dataset, scaled splits, scaler, model, training result, forecast, and diagnostic metadata.

To request the optional local Hyperband path from the command line, use:

python scripts/run_reproduction.py \
    --data-dir data/raw \
    --output-dir artifacts \
    --assets AAPL MSFT \
    --evaluation-subset validation \
    --tune

Without --tune, the command uses the reported Table 1 configurations. With --tune, it uses the generated search space and records tuner artifacts separately for each asset. Neither invocation silently merges testing and validation, and neither command's numerical outcome is claimed here.

What this stage establishes—and what it does not

This implementation preserves the paper's main training constraints: separate asset models, validation-loss selection, Adam at learning rate 0.001, MSE loss, batch size 8, at most 100 epochs, patience 7, and best-weight checkpointing. It also makes unresolved choices visible: tuner ranges, random seed, Hyperband details, Keras defaults, callback behavior, and evaluation protocol.

The paper's reported AAPL and MSFT validation-loss values and forecast metrics remain reference values, not achieved results. Under the supplied run policy, no training, tuning, code execution, tests, static verification, semantic code verification, tutorial verification, or final quality review was performed.

Inverse Transformation, Forecast Metrics, and Reference Comparison

How do normalized neural-network outputs become interpretable stock-price forecasts? The evaluation stage reverses the training-time scaling, pairs every prediction with the date it forecasts, and computes errors in original adjusted-price units. It also makes one unresolved protocol choice visible: whether to evaluate the testing partition or the validation partition.

The paper describes its reported evaluation as validation-based, but it also defines separate training, testing, and validation subsets. Because the supplied paper description does not explain whether testing was ignored, evaluated separately, or used in another way, the generated implementation never merges these partitions silently. The caller must choose one explicitly.

Paper facts and implementation decisions

The paper reports separate results for AAPL and MSFT. It evaluates next-period adjusted closing prices with MSE, MAE, MAPE, and forecast accuracy. The supplied series contain positive prices, so percentage error is defined for these inputs.

The implementation makes several choices explicit rather than presenting them as recovered paper details:

  • validation is the default command-line evaluation choice, but test is also available.

  • The scaler is the one fitted from training data only.

  • Predictions and targets are inverse-transformed before price-unit metrics are calculated.

  • A nonpositive actual price is rejected for MAPE rather than handled with an invented zero-denominator rule.

  • The paper's Table 3 values are comparison references, not guaranteed outputs from this implementation.

No canonical metric equations were supplied in the source context. Accordingly, this section explains the metric contracts and their code mappings in prose rather than reconstructing formulas.

1. Select exactly one held-out partition

After windowing and chronological splitting, each asset has 156 training samples, 52 testing samples, and 52 validation samples. The select_evaluation_subset function accepts a DatasetSplits object and returns the inputs, targets, and target dates from exactly one held-out partition.

inputs, scaled_targets, target_dates = select_evaluation_subset(
    splits,
    subset="validation",
)

print(inputs.shape)          # expected: (52, 3, 1)
print(scaled_targets.shape)  # expected: (52, 1)
print(target_dates.shape)    # expected: (52,)

To select the testing partition instead, change only the explicit protocol argument:

test_inputs, test_targets, test_dates = select_evaluation_subset(
    splits,
    subset="test",
)

The function returns three aligned objects:

  • input windows with shape (52, 3, 1);

  • normalized scalar targets with shape (52, 1); and

  • a chronological DatetimeIndex of 52 target dates.

Its failure case is deliberate: any subset other than test or validation raises EvaluationProtocolError. This prevents a misspelled or implicit evaluation choice from producing an apparently valid report. The function also does not concatenate the two held-out partitions.

2. Predict, restore price units, and preserve dates

The GRU receives normalized windows and should produce one normalized scalar per window. evaluate_forecasts validates this contract, calls the model's predict method, converts predictions and targets back through the same training-fitted scaler, and then constructs a ForecastResult.

result = evaluate_forecasts(
    model=model,
    inputs=inputs,
    targets=scaled_targets,
    dates=target_dates,
    scaler=scaler,
)

print(result.dates.shape)       # expected: (52,)
print(result.actuals.shape)     # expected: (52,)
print(result.predictions.shape) # expected: (52,)
print(result.metrics)

The returned ForecastResult contains dates, actuals, predictions, and metrics. The actuals and predictions are one-dimensional price arrays. For either default held-out partition, each has length 52.

The function checks several invariants before asking the model to forecast:

  • inputs must have shape (n, 3, 1);

  • targets may have shape (n,) or (n, 1), then are normalized internally;

  • dates must be unique, nonmissing, and chronological;

  • inputs and targets must contain the same number of samples; and

  • all numeric values must be finite.

It also requires a model with a callable predict method. A prediction returned as (n, 1) is converted to (n,); an incompatible output shape, a failed prediction, or a non-finite prediction raises EvaluationProtocolError.

The lower-level inverse_transform_forecasts helper performs only the restoration step. It applies the same scaler to normalized predictions and normalized targets, then returns equal-length one-dimensional arrays in adjusted-price units:

from stock_forecasting.evaluation.forecasting import inverse_transform_forecasts

price_predictions, price_targets = inverse_transform_forecasts(
    scaler,
    normalized_predictions,
    scaled_targets,
)

Using one training-fitted scaler for both arrays is important. Applying a separately fitted evaluation scaler would change the meaning of the predictions and could introduce information from the held-out data. The paper specifies training-only fitting, but it does not resolve whether fitting used the raw training prices or flattened training windows and targets. That convention must therefore be recorded with the result artifact.

3. Understand the metric units

Once arrays are back in price units, compute_forecast_metrics delegates to the individual metric functions. The public interface is concise:

from stock_forecasting.evaluation.metrics import compute_forecast_metrics

metrics = compute_forecast_metrics(
    actuals=result.actuals,
    predictions=result.predictions,
)

print(f"MSE: {metrics.mse:.2f}")
print(f"MAE: {metrics.mae:.2f}")
print(f"MAPE: {metrics.mape:.2f}%")
print(f"Forecast accuracy: {metrics.forecast_accuracy:.2f}%")

The fields have different interpretations:

  • MSE is mean squared error. Its unit is squared adjusted-price units, so large errors receive disproportionately more weight.

  • MAE is mean absolute error in adjusted-price units. It is easier to read as a typical price-distance measure than MSE.

  • MAPE is mean absolute percentage error, reported in percentage points.

  • Forecast accuracy follows the paper's convention of taking 100 minus MAPE, also in percentage points.

The last quantity is not classification accuracy. It does not measure whether the model correctly predicted an upward or downward movement. It is simply the paper's complement of a percentage error.

The metric functions validate equal-length, one-dimensional, finite arrays. compute_mape additionally requires every actual price to be strictly positive. This matches the supplied AAPL and MSFT data and avoids silently choosing how to divide by zero or a negative price. The generated implementation raises EvaluationProtocolError when that assumption is violated.

4. Worked example: validation forecast and date-aligned plot

The following sequence makes the evaluation protocol visible: choose validation data, evaluate it, and plot the returned date-aligned result. The model and scaler are assumed to have been produced by the preceding training pipeline; this excerpt does not claim that training or forecasting was executed.

from pathlib import Path

from stock_forecasting.evaluation.forecasting import (
    evaluate_forecasts,
    select_evaluation_subset,
)
from stock_forecasting.eda.plots import plot_forecast, save_figure

inputs, scaled_targets, target_dates = select_evaluation_subset(
    splits,
    subset="validation",
)

result = evaluate_forecasts(
    model=model,
    inputs=inputs,
    targets=scaled_targets,
    dates=target_dates,
    scaler=scaler,
)

axis = plot_forecast(result)
save_figure(
    axis.figure,
    Path("artifacts/forecasts/AAPL_validation_forecast.png"),
)

plot_forecast reads the ForecastResult without mutating it. It checks that dates, actuals, and predictions have equal lengths, then draws both series against the target dates. Those dates are not the dates at the beginning of each input window: they identify the following observation that served as the target. This distinction keeps a forecast visually aligned with the price it was intended to predict.

To inspect testing separately, repeat the same process with subset="test" and write a distinct artifact name. Do not label a validation result as a test result, since the two partitions represent different chronological portions of the data.

5. Compare with Table 3 without claiming reproduction

The paper reports these reference values:

| Asset | MSE | MAE | MAPE | Forecast accuracy | |---|---:|---:|---:|---:| | AAPL | 55.80 | 6.11 | 3.37% | 96.63% | | MSFT | 144.44 | 9.41 | 2.55% | 97.45% |

compare_to_reference retrieves the appropriate paper reference, records the computed value, reference value, and signed difference for every metric, and can optionally calculate absolute-tolerance indicators. format_reference_report renders those fields as a readable report.

from stock_forecasting.evaluation.reference import (
    compare_to_reference,
    format_reference_report,
)

comparison = compare_to_reference(
    asset="AAPL",
    metrics=result.metrics,
)

print(format_reference_report({"AAPL": comparison}))

A comparison with an optional tolerance is still descriptive rather than a proof of reproducibility:

comparison = compare_to_reference(
    asset="MSFT",
    metrics=msft_result.metrics,
    tolerance=1.0,
)

print(format_reference_report({"MSFT": comparison}))

The report keeps units explicit: MSE and MAE differences use price-based units, while MAPE and forecast-accuracy differences use percentage points. Without a tolerance, the report states that tolerance was not assessed. With one, it reports whether each absolute difference falls within that numerical threshold; it does not establish that the data, training process, or evaluation protocol matched the paper.

6. Offline artifact reporting

The generated scripts/report_results.py script reads JSON forecast artifacts and reports their generated metrics separately from Table 3. Its load_result_artifact function requires dates, actuals, predictions, and all four metric fields. It rejects missing fields, invalid dates, unequal array lengths, non-finite values, and nonpositive actuals.

A typical command shape is:

python scripts/report_results.py \
    --artifact-dir artifacts/results \
    --assets AAPL MSFT \
    --tolerance 1.0

This reporter is intentionally offline. It does not download data or infer a missing evaluation subset from the JSON. The artifact-producing workflow should retain the asset name, selected subset, scaler convention, model configuration, target dates, and metric units alongside the arrays. That metadata is necessary when a discrepancy with Table 3 could arise from more than the GRU itself.

7. Interpreting absolute and relative errors

The paper reports larger absolute errors for MSFT—MSE 144.44 and MAE 9.41—than for AAPL—MSE 55.80 and MAE 6.11. MSFT nevertheless has the lower MAPE, 2.55% compared with AAPL's 3.37%, and therefore the higher reported forecast accuracy, 97.45% compared with 96.63%.

These values illustrate why both absolute and relative measures matter. A stock with a higher price level can have a larger dollar error while that error represents a smaller fraction of the actual price. The metrics answer different questions: MAE asks about price-unit distance, whereas MAPE asks about proportional distance.

These figures remain paper references in this reproduction. Exact numerical agreement is not guaranteed because the supplied context leaves the source-data version, weekly aggregation, scaler-fitting convention, random seed, complete Hyperband search space, Keras defaults, callback details, and reported evaluation-subset procedure incomplete.

8. Evaluation checklist

Before interpreting a forecast artifact, confirm that:

  • the asset is recorded as either AAPL or MSFT and was modeled independently;

  • one explicit subset, test or validation, is recorded;

  • inputs have shape (52, 3, 1) for the selected default held-out partition;

  • dates, actuals, and predictions have equal length and chronological alignment;

  • the scaler was fitted on training data only and reused for inverse transformation;

  • metrics were calculated after returning predictions and targets to price units;

  • MSE and MAE are reported with price-based units;

  • MAPE and forecast accuracy are reported as percentages;

  • forecast accuracy is interpreted as 100 minus MAPE, not as classification accuracy; and

  • Table 3 values are labeled as references rather than achieved results.

The generated metric tests describe these invariants, including inverse transformation, positive-price validation, the forecast-accuracy complement, and date preservation. However, the run policy disabled execution, test generation, static verification, semantic code verification, tutorial verification, and final review. No test result or numerical reproduction claim is made here.

Limitations, Static and Semantic Verification Status, and Reproduction Checklist

What would make this reproduction trustworthy—and what can it not establish? The workflow is a useful, structured experiment: it keeps AAPL and MSFT separate, preserves chronological order, prevents later values from fitting the scaler, and records the choices needed to turn the paper description into Python. It is not a guarantee that a GRU will forecast future markets reliably.

The study uses only 263 weekly adjusted closing-price observations for each of two technology stocks. Each model sees only three preceding prices and predicts one next price. That narrow design is faithful to the paper's scope, but it limits generalization. Market conditions can change, and a price-history-only model may smooth abrupt movements or behave differently under distribution shift. The resulting forecasts should therefore support analysis and financial-risk decisions, not operate as an autonomous trading system.

A final separation of evidence

A responsible reproduction distinguishes three categories:

  • Paper facts: the separate AAPL and MSFT models, adjusted-price inputs, three-period windows, chronological partitions, Table 1 architectures, training settings, and Table 3 reference metrics.

  • Derived structure: 263 raw observations minus a three-period window produces 260 supervised samples, which are partitioned as 156 training, 52 testing, and 52 validation samples.

  • Implementation decisions: local CSV input instead of live retrieval; the selected scaler convention; decomposition, ADF, and autocorrelation parameters; Keras defaults not specified by the paper; callback details; and the locally defined Hyperband search space.

This distinction is especially important when comparing numbers. The paper's reference values—AAPL MSE 55.80, MAE 6.11, MAPE 3.37%, and forecast accuracy 96.63%; MSFT MSE 144.44, MAE 9.41, MAPE 2.55%, and forecast accuracy 97.45%—are targets for comparison. They are not evidence that this generated code achieved those values.

What can prevent exact numerical reproduction

Even with the same broad workflow, results can differ because the supplied paper description does not fully specify:

  • the exact Yahoo Finance data version, retrieval query, weekly aggregation rule, and calendar handling;

  • whether the scaler was fitted on raw training prices or flattened training windows and targets;

  • random seeds and framework initialization;

  • GRU activations, initializers, sequence-output settings, and the terminal output construction;

  • the Hyperband search space, iterations, seed, and stopping rules;

  • early-stopping and checkpoint details; and

  • whether the reported evaluation used validation only, testing only, or another unresolved protocol.

The generated pipeline makes these uncertainties visible. It keeps the testing and validation partitions distinct and requires an explicit evaluation_subset choice. It also records the selected scaler convention and diagnostic parameters rather than implying that they were recovered from the paper.

Verification status: skipped, not passed

The run policy disabled execution, test generation, local static verification, semantic code verification, tutorial verification, and final quality review. The local verification record consequently reports verification_skipped, not a successful check. Semantic code verification was also skipped. No claim is made here that the package imports correctly, that a model trained, that tests passed, or that any metric was reproduced.

Static verification and semantic verification would answer different questions if they were later enabled. Static checks could inspect Python parsing, imports, dependency order, configuration consistency, tensor shapes, chronology, and leakage boundaries without training a model. Semantic review would examine whether the implementation's behavior actually matches the intended method—for example, whether only training values fit the scaler, whether testing data stays out of training, and whether dates remain aligned with target observations. Neither kind of check was completed in this run.

Worked example: the complete local workflow

The intended workflow begins with local appendix-style files. The repository does not download data or pretend to reproduce an unspecified Yahoo Finance retrieval procedure. Prepare AAPL.csv and MSFT.csv under a local input directory, with date and adjusted_close columns:

python scripts/prepare_local_data.py \
  --input-dir appendix \
  --output-dir data/raw

The preparation script validates the local records and writes the normalized schema. Its public function preserves the paper's 263-row contract:

def prepare_asset_file(
    source_path: Path,
    destination_path: Path,
    asset: str,
) -> None:
    """Validate a local appendix CSV and write its normalized raw-data form.

    The input is never downloaded or augmented.  The loader accepts the paper's
    date and adjusted-close fields (including documented column aliases), while
    this function writes the stable schema expected by later pipeline stages:
    ``date``, ``adjusted_close``.  Records are ordered chronologically and must
    contain exactly the paper's 263 observations.
    """

Next, generate non-mutating diagnostics for both assets:

python scripts/run_diagnostics.py \
  --data-dir data/raw \
  --output-dir artifacts/diagnostics

These reports and figures describe the data; they do not add decomposition components or other diagnostics to the GRU inputs. The generated command records implementation choices such as the decomposition period, ADF settings, and autocorrelation lag range.

Finally, run the reproduction with an explicit evaluation partition:

python scripts/run_reproduction.py \
  --data-dir data/raw \
  --output-dir artifacts \
  --evaluation-subset validation

To keep the testing partition separate but inspect it instead, choose test:

python scripts/run_reproduction.py \
  --data-dir data/raw \
  --output-dir artifacts \
  --evaluation-subset test

The --tune flag is another explicit choice. Without it, run_asset_pipeline uses the reported Table 1 configuration directly. With it, the implementation runs its documented, locally selected Hyperband search space; that search is not claimed to be the paper's unrecovered search space.

At the Python level, one asset follows the same sequence of responsibilities:

result = run_asset_pipeline(
    asset=asset,
    data_dir=data_dir,
    output_dir=output_dir / asset,
    evaluation_subset=args.evaluation_subset,
    tune=args.tune,
)

The returned AssetRunResult is intended to retain the raw PriceSeries, diagnostic record, WindowedDataset, scaled DatasetSplits, scaler, model, training artifact, and ForecastResult. This makes the pipeline inspectable: a reviewer can trace an asset from its 263 observations through 260 windows and the 156/52/52 partition to its selected held-out forecast. AAPL and MSFT are processed in separate calls, so their scalers, models, checkpoints, and metrics do not get combined.

Practical reproduction checklist

Use the following checklist when execution and verification are available:

  1. Prepare inputs. Supply one local CSV per asset with date and adjusted_close; do not download or infer missing records.

  2. Validate raw data. Confirm 263 rows, chronological dates, finite positive adjusted prices, and no missing values.

  3. Run diagnostics. Save descriptive statistics, plots, decomposition, ADF, and autocorrelation outputs. Treat them as diagnostic artifacts only.

  4. Create windows. Confirm 260 samples with inputs shaped (260, 3, 1), targets shaped (260, 1), and dates aligned to target observations.

  5. Check partitions. Confirm contiguous chronological sizes of 156 training, 52 testing, and 52 validation samples.

  6. Check scaling. Confirm that only training values fit the chosen scaler and that later subsets are transformed without refitting.

  7. Build models separately. Confirm AAPL widths (240, 16, 16) and MSFT widths (224, 48), with zero dropout and one scalar output per sample.

  8. Train carefully. Confirm Adam at learning rate 0.001, MSE loss, batch size 8, at most 100 epochs, patience 7, validation monitoring, and best-checkpoint use.

  9. Select evaluation data explicitly. Record whether the held-out subset is validation or test; never merge them silently.

  10. Evaluate in price units. Inverse-transform predictions and targets with the training-fitted scaler before computing MSE, MAE, MAPE, and forecast accuracy.

  11. Compare transparently. Use compare_to_reference to report differences from Table 3, while labeling those values as paper references rather than pass/fail verification.

  12. Record the environment. Preserve data provenance, dependency versions, random seeds, scaler convention, tuner settings, callback settings, and the evaluation choice.

The paper's conclusions also suggest what a stronger future study would need: more assets and time periods, rolling-origin evaluation, benchmark models, uncertainty estimates, confidence intervals, statistical significance analysis, and potentially technical, sentiment, volatility, or macroeconomic features. Those are sensible extensions, but they are outside this reproduction and must not be presented as part of the implemented method.

The central lesson is procedural rather than predictive: document the evidence boundary, preserve time order, expose unresolved choices, and never turn an unexecuted or unverified pipeline into a claimed result.

Use the button or URL below to download the source code.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2026 Onepagecode · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture