DEV Community

Cover image for If You Learn Slowly Enough, You Won’t Need to Learn Anything—Applying the TabFM Prediction Framework to Quantitative Trading
Dream
Dream

Posted on

If You Learn Slowly Enough, You Won’t Need to Learn Anything—Applying the TabFM Prediction Framework to Quantitative Trading

Recently, Google Research released TabFM, a foundation model designed for tabular classification and regression tasks.

It attempts to compress model training, hyperparameter search, and complex feature engineering in traditional tabular machine learning into a more direct workflow: give the model a set of labeled historical samples, provide one new row, and let the model make a prediction in context. (Google Research)

When I first saw this framework, the first application that came to mind was quantitative trading.


Quantitative research has always been about producing tables. Candlestick data, trading volume, funding rates, basis, and open interest all eventually get organized into rows and passed to a model.

This time, we adopted a very direct approach:


Do not calculate RSI. Do not calculate MACD. Do not use moving averages or predesign complex factors. Simply convert a sequence of candlesticks into a time window and let TabFM determine whether the next candlestick is more likely to move up, move down, or remain flat.

This does not conflict with the traditional approach of calculating moving averages, RSI, or ATR.

Traditional indicators also begin by manually defining a time window and then use a fixed formula to extract features from that window.

The difference is that, in the past, we first decided whether the model should observe averages, directional strength, or volatility. Now, we only provide the raw time window and let TabFM search for potentially useful relationships on its own.

That is the most interesting part of this framework.

1. Turning a Time Window into a Single Row

Suppose we define a candlestick window with a length of window.

Each candlestick contains:

  • Open
  • High
  • Low
  • Close
  • Volume Each sample uses the window completed candlesticks immediately preceding the target candlestick. The label is the direction of the target candlestick that follows.

For example, when preparing to predict the next candlestick, the latest input consists of a number of candlesticks that have already completed.

Historical training samples are then shifted backward one candlestick at a time:


Training samples are ordered from newest to oldest.

Within each row, the most recent data also comes first:

  • lag_1 represents the candlestick closest to the target;
  • lag_2 represents the candlestick two periods before the target;
  • lag_window represents the earliest candlestick in the window. The feature-column generation code is as follows:
def make_feature_columns(window):
    columns = []

    for lag in range(1, window + 1):
        prefix = "lag_{}".format(lag)

        columns.extend([
            prefix + "_open",
            prefix + "_high",
            prefix + "_low",
            prefix + "_close",
            prefix + "_volume",
        ])

    return columns

Enter fullscreen mode Exit fullscreen mode

If the window length is 15, each row contains 75 fields. If the window length is 24, each row contains 120 fields.

The window length, candlestick timeframe, and number of historical samples are all parameters. More suitable combinations can be found through rolling backtests or parameter search; none of them are fixed answers.

2. Why Not Feed Absolute Prices Directly into the Model?

Although we use raw candlestick data, we should not pass absolute prices to the model unchanged.

For example, after BTC rises from 30,000 to 100,000, the price scale has changed significantly. If absolute values are used directly, the model may incorrectly treat the price level itself as a stable pattern.

Therefore, each row uses the closing price closest to the prediction target as an anchor.

Prices are converted into percentage changes relative to the anchor, while volume is divided by the average volume within the window.

def make_raw_kline_row(
    bars,
    target_idx,
    window,
):
    anchor_close = float(
        bars[target_idx - 1]["Close"]
    )

    if anchor_close <= 0:
        anchor_close = 1.0

    volumes = [
        float(
            bars[target_idx - lag].get(
                "Volume",
                0.0,
            )
        )
        for lag in range(1, window + 1)
    ]

    mean_volume = (
        sum(volumes) / len(volumes)
        if volumes
        else 1.0
    )

    if mean_volume <= 0:
        mean_volume = 1.0

    row = {}

    for lag in range(1, window + 1):
        bar = bars[target_idx - lag]
        prefix = "lag_{}".format(lag)

        row[prefix + "_open"] = (
            float(bar["Open"]) - anchor_close
        ) / anchor_close

        row[prefix + "_high"] = (
            float(bar["High"]) - anchor_close
        ) / anchor_close

        row[prefix + "_low"] = (
            float(bar["Low"]) - anchor_close
        ) / anchor_close

        row[prefix + "_close"] = (
            float(bar["Close"]) - anchor_close
        ) / anchor_close

        row[prefix + "_volume"] = (
            float(bar.get("Volume", 0.0))
            / mean_volume
        )

    return row

Enter fullscreen mode Exit fullscreen mode

No traditional indicators are calculated here.

We are only normalizing the price and volume scales across different periods.

3. What Does the Model Need to Predict?

This time, the task is defined as a three-class classification problem:

  • up
  • down
  • flat The label represents the change in the target candlestick relative to the previous closing price.
def make_next_bar_label(
    bars,
    target_idx,
    threshold,
):
    previous_close = float(
        bars[target_idx - 1]["Close"]
    )

    target_close = float(
        bars[target_idx]["Close"]
    )

    if previous_close <= 0:
        return "flat"

    target_return = (
        target_close / previous_close - 1.0
    )

    if target_return > threshold:
        return "up"

    if target_return < -threshold:
        return "down"

    return "flat"

Enter fullscreen mode Exit fullscreen mode

threshold is used to separate upward movement, downward movement, and flat movement.

This threshold is not fixed either.

Different instruments, candlestick timeframes, and trading costs require different reasonable thresholds.

4. How Historical Samples Are Generated

Historical data begins with the latest sample and gradually moves backward in time.

def build_dataset(
    bars,
    window,
    train_rows,
    return_threshold,
):
    minimum_bars = window + train_rows

    if len(bars) < minimum_bars:
        raise ValueError(
            "Insufficient candlestick data. At least {} bars are required.".format(
                minimum_bars
            )
        )

    rows = []
    labels = []

    latest_known_target_idx = len(bars) - 1

    for offset in range(train_rows):
        target_idx = (
            latest_known_target_idx - offset
        )

        rows.append(
            make_raw_kline_row(
                bars=bars,
                target_idx=target_idx,
                window=window,
            )
        )

        labels.append(
            make_next_bar_label(
                bars=bars,
                target_idx=target_idx,
                threshold=return_threshold,
            )
        )

    latest = make_raw_kline_row(
        bars=bars,
        target_idx=len(bars),
        window=window,
    )

    return rows, labels, latest

Enter fullscreen mode Exit fullscreen mode

The final result contains three parts:

  • rows: historical time windows;
  • labels: the actual direction of the candlestick following each window;
  • latest: the latest time window, used to predict the next candlestick that has not yet appeared. Here, rows[0] is the latest historical sample, and subsequent rows become progressively older.

Meanwhile, latest uses only candlesticks that have already completed and contains no future data.

5. How Is This Different from Moving Averages and RSI?

Suppose we observe the same time window.

A traditional approach would first define indicators manually.

For example, a moving average:

ma = sum(close_list[-window:]) / window

Enter fullscreen mode Exit fullscreen mode

RSI calculates the average strength of upward and downward movements within the window.

ATR calculates true range.

What these methods have in common is:

A human first decides which relationship is useful, then compresses the entire time window into a single indicator.

TabFM takes a different approach.

We still define the time window, but we do not specify in advance what the model must observe.

The model can directly read all OHLCV fields inside the window and attempt to identify:

  • whether closing prices are rising continuously;
  • whether highs and lows are moving upward together;
  • whether candlestick bodies are expanding;
  • whether upper and lower shadows are changing;
  • whether volume is increasing;
  • whether volatility is contracting or expanding;
  • whether combined relationships exist across different positions in the window.

Traditional indicators are handcrafted features.

Here, the raw window is passed to a foundation model, allowing the model to search for potentially useful feature combinations on its own.

This is the aspect of TabFM that deserves the most attention in quantitative applications.

We still define the time range and prediction target, but we no longer have to enumerate a large number of indicators in advance.

6. It Is Not TimesFM

TabFM is not a dedicated time-series model.

Google's TimesFM is the foundation model designed to read continuous time series directly and predict future sequences. (Google Research)

TabFM receives a table with a fixed column structure.

In our data, temporal relationships are expressed manually through field positions:

  • lag_1 is always the most recent candlestick;
  • lag_2 is always the candlestick two periods back;
  • lag_window is always the earliest candlestick in the window.

Therefore, a more accurate description is:

We use a manually defined time window to convert a time-series problem into a tabular classification problem, and then use TabFM to make the prediction.

This is similar to the use of lag features in traditional machine learning.

The difference is that TabFM is a tabular foundation model. It uses historical samples as context and does not require retraining a new set of model parameters specifically for the current dataset. (Google Research)

7. Calling TabFM

Once the data has been prepared, the actual model invocation code is very short.

import numpy as np
import pandas as pd

from tabfm import TabFMClassifier
from tabfm import tabfm_v1_0_0_jax


model = tabfm_v1_0_0_jax.load(
    model_type="classification"
)

classifier = TabFMClassifier(
    model=model,
    random_state=42,
)

rows, labels, latest = build_dataset(
    bars=bars,
    window=window,
    train_rows=train_rows,
    return_threshold=return_threshold,
)

x_train = pd.DataFrame(rows)
y_train = np.asarray(labels)

x_test = pd.DataFrame(
    [latest],
    columns=x_train.columns,
)

classifier.fit(
    x_train,
    y_train,
)

probabilities = (
    classifier.predict_proba(x_test)[0]
)

Enter fullscreen mode Exit fullscreen mode

Organize the prediction results:

probs = {
    str(class_name): float(probability)
    for class_name, probability in zip(
        classifier.classes_,
        probabilities,
    )
}

for class_name in (
    "up",
    "flat",
    "down",
):
    probs.setdefault(
        class_name,
        0.0,
    )

label = max(
    probs,
    key=probs.get,
)

confidence = probs[label]

Enter fullscreen mode Exit fullscreen mode

The result is a probability for whether the next candlestick will move up, move down, or remain flat.

TabFM's fit() is not traditional retraining either.

Historical samples are primarily used as contextual input, allowing the model to infer the relationship between features and labels in the current table before predicting the latest sample. (GitHub)

8. Installation Notes

TabFM currently requires Python 3.11.

conda create -n py311 python=3.11 -y
conda activate py311

Enter fullscreen mode Exit fullscreen mode

Install the JAX backend:

git clone https://github.com/google-research/tabfm.git
cd tabfm

python -m pip install \
    --upgrade pip setuptools wheel

python -m pip install -e '.[jax]'

Enter fullscreen mode Exit fullscreen mode

The pretrained weights will also be downloaded the first time the model is loaded.

model = tabfm_v1_0_0_jax.load(
    model_type="classification"
)

Enter fullscreen mode Exit fullscreen mode

In this experiment, the JAX model download was approximately 6 GB.

The pretrained weights currently use the TabFM non-commercial license. Before using them in production or for commercial purposes, you should verify the scope of the license yourself. (Hugging Face)

When using an FMZ Quant local host, you also need to confirm that the Python interpreter used by the strategy is the same environment in which TabFM was installed.

import sys

Log(
    "Python executable: "
    + sys.executable
)

Log(
    "Python version: "
    + sys.version
)

Enter fullscreen mode Exit fullscreen mode

If TabFM can be imported successfully in the terminal but the strategy reports that the module cannot be found, the local host is usually using a different Python environment.

9. Why It Is Currently Better Suited to Low-Frequency Prediction

TabFM is not currently a lightweight model.

The first run involves model loading, JAX initialization, XLA compilation, and CPU inference, making it significantly slower than traditional models such as LightGBM and XGBoost.

For that reason, it is not currently suitable for tick-by-tick, second-level, or order-book-level prediction.

A more practical approach is:

  • make one prediction when the strategy starts;
  • update predictions at fixed intervals afterward;
  • continue using conventional code for position monitoring, stop-loss handling, and trade execution. The prediction frequency should ideally match the candlestick timeframe.

For example, when using hourly candlesticks, predict the next one after a new hourly candlestick has completed. When using 15-minute candlesticks, update the prediction after each 15-minute candlestick closes.

To control inference frequency, the complete example in this experiment runs once at startup and then updates once every clock hour.

10. What This Experiment Actually Demonstrates

This experiment does not prove that TabFM can beat the market.

It has not undergone complete out-of-sample testing, nor has it been systematically compared with traditional indicators, LightGBM, XGBoost, or dedicated time-series models.

But it demonstrates a change that deserves attention.

In the past, when facing a candlestick window, our first question was which indicators we should calculate.

Now, we can preserve the raw data inside the window first and let a foundation model search for potentially useful combinations on its own.

The time window is still defined by humans.

The labels are still defined by humans.

The window length, data timeframe, and number of samples still need to be selected through backtesting.

But the features extracted from inside the window no longer have to be specified entirely in advance by humans.

That is the clever part of TabFM.

What it lowers is not the barrier to understanding the market, but the operational barrier to feature engineering and model training.

So the idea that "if you learn slowly enough, you do not need to learn anything at all" does not mean that knowledge is no longer important.

What has actually changed is this:

In the past, we might have needed to learn a large number of indicators, models, and parameters before starting a prediction experiment.

Now, we can begin by answering a few more fundamental questions:

  • How long should the observation window be?
  • What should the prediction target be?
  • How should the labels be defined?
  • Which historical samples should be provided?
  • How should we verify whether the prediction has trading value?

Part of the remaining feature-discovery work can be delegated to the foundation model.

Models will become increasingly general, and the code will become shorter.

But what is worth predicting, how the data should be organized, and whether the result can actually be traded are still questions that researchers must answer for themselves.

Top comments (0)