Labeling 20K Market Bars to Train ML for Tops and Bottoms
Hyde Park, University of Chicago. Chicago is an economics powerhouse, home to the famous Chicago School, with 100 Nobel laureates, 10 Fields Medalists, and 4 Turing Award winners to its name. The Alpha quants chase today traces back to Michael Jessen's doctoral dissertation at Chicago.
Many people are curious about machine learning-based quant strategies and often ask when a machine learning course is coming. Honestly, for many of us — myself included — we have no ability to improve machine learning algorithms or frameworks. We use machine learning as a black box, and the real bottleneck is training data.
In this post, I'll introduce a data-labeling method and tool.
Supervised machine learning requires labeled data. Labeled data is typically a 2D matrix with one column for labels (usually denoted y) and the rest for features (usually denoted X). Training looks like:
$$
fit(x) = WX -> y' \approx y
$$
Training adjusts the weight matrix $W$ via backpropagation so that the resulting $y'$ is as close as possible to $y$.
The feature matrix isn't hard. It can simply be factor values at a given point in time. Labeling is the hard part. It really reflects how you understand the logical relationship between factors and labels: can factors predict the future price of the underlying, or the future path of its price?
How to Label Data
A few years ago, a paper using LSTM to predict stock prices went viral. Some master's programs combining AI and finance even assign similar problems to students for practice.
As an exercise, that's fine. But it should be made clear how absurd it is to predict stock prices with LSTM: you cannot derive the next price directly from price using noisy, time-series financial data.
There's also a popular workaround: since the relationship between data and labels isn't logistic regression, discretize the labels to turn it into a classification problem. For example, classify next-day moves above 3% as a big rally, moves between 1% and 3% as a small rally, and moves between -1% and 1% as directionless.
In fact, the logic behind this approach is still logistic regression. And why is +2.99% a small rally but +3% a big rally? Some propose adding a gap between classes — e.g., [-0.5%, 0.5%] as directionless and [1%,3%] as a small rally, while dropping data in [0.5%, 1%] from training. These tricks sometimes work in other domains, but in quant I don't think they're good enough. The premise is wrong.
We should go back to first principles. Predicting up or down every single day is hard. But judging whether a trend has ended is relatively easier — there are more features to work with and less randomness. In mathematical terms, we can label the top of a candlestick run as 1, the bottom as -1, and everything in between as 0. Every peak has a matching trough, but the in-between points are far more numerous, so the classes are imbalanced. During training, just downsample the 0-labeled portion to balance the classes.
Labeling Swing Tops and Bottoms
Based on the thinking above, I built a small tool to label tops and bottoms in market data.

The tool needs to:
- Load a slice of market data and plot a candlestick chart
- Automatically detect tops and bottoms in the slice and mark them on the chart
- Extract the timestamps of those tops and bottoms into peak and valley edit boxes for manual correction
- After calibration, click "Record > Next" to label the next slice
We use the zigzag library to automatically find tops and bottoms. Compared to argrelextrema and find_peaks in the scipy.signals package, peaks_valleys_pivot in zigzag is much better suited to price data — methods like find_peaks demand data quality far higher than noisy financial data can deliver.
peaks_valleys_pivot automatically marks the first and last bars as peaks or valleys — which is often wrong — because the move hasn't finished and the trailing mark isn't finalized yet. So we need to manually remove those marks. Occasionally you'll also see marks that are too dense — usually when price whipsaws violently, but if it is quickly repaired, we can simply leave that segment unlabeled. That also requires manual removal.
Finally, we save the OHLC, volume, and top/bottom marks together. We'll end up with something like this:

Of course, this is only a draft of our training data. As noted, we can't use raw price data directly for training. We have to extract features from it. Reversal-type indicators like RSI are obviously good candidates.
Other informative features include spike-and-reversal, changes in the slope of a moving-average tangent (positive-to-negative signals a top, and vice versa for a bottom), two failed pushes against a high, and candlestick patterns like morning star and evening star (if you resample their candles, they're really a spike-and-reversal process — a long upper or lower shadow).
How I Built the Labeling Tool
tip
Here we show how to build the UI with ipywidgets in Jupyter. Plotly Dash, streamlit, and H2O Wave are also designed primarily for this purpose.from ipywidgets import Button, HBox, VBox, Textarea, Layout,Output, Box
from IPython.display import display
In a cell, if the final output is an object, the notebook will display that object directly. If you want to display multiple objects in one cell, or display objects in the middle of your code, you need the display method. That's why we imported display above.
Here we imported three container widgets — HBox, VBox, and Box — plus functional widgets like Button and TextArea.
Layout controls widget styling. For example, to set the width and height of a peak-time input box:
peaks_box = Textarea(
value='',
placeholder='请输入峰值时间,每行一个',
description='峰值时间',
layout=Layout(width='40%',height='100px')
)
Button widgets typically need an action on click. We bind the click event to a handler via the on_click method:
save_button = Button(
description='存盘'
)
save_button.on_click(save)
def save(c):
# SAVE DATA TO DISK
pass
Note that the event handler (save here) must take one argument in its signature. Otherwise, when the button is clicked, the event won't propagate to the function, with no error message at all.
HBox and VBox arrange child widgets in rows and columns. For example:
# K 线图的父容器
figbox = Box(layout=Layout(width="100%"))
inputs = HBox((peaks_box, valleys_box))
buttons = HBox((backward_button, keep_button, save_button, info))
display(VBox((buttons, inputs, figbox)))
The Output widget is a special case. If you print inside an event handler, the output won't appear below the cell like prints in other cells. You must define an Output widget, which will capture printed messages and show them in its display area.
info = Output(layout=Layout(width="40%"))
def save(c):
global info
# DO THE SAVE JOB
with info:
print("数据已保存到磁盘!")
Similarly, a candlestick chart drawn with plotly can't be displayed directly. We display it via go.FigureWidget.
import plotly.graph_objects as go
figure = ... # draw the candlestick with bars
fig = go.FigureWidget(figure)
figbox.children = (fig, )
We show this snippet specifically to demonstrate how to swap the candlestick chart. At initialization, we must lay out figbox together with the other widgets — but how do we update its contents?
The answer: make figbox a container, and go.FigureWidget one of its children. Each time you need to update the chart, generate a new fig object and replace it via figbox.children = (fig, ).
Finally, a troubleshooting tip. Event functions bound via on_click won't show any message even if they crash at runtime. So you need to catch errors yourself and display the traceback through an Output widget:
def log(msg):
global info
info.clear_output()
with info:
if isinstance(msg, Exception):
traceback.print_exc(msg)
else:
print(msg)
def on_save(b):
try:
# DO SOMETHING MAY CAUSE CHAOS
raise ValueError()
except Exception as e:
log(e)
info = Output(layout = Layout(...))
save_button = Button()
save_button.on_click(on_save)
Using this tool, in about two hours I ended up with 20,000 bars, including about 1,600 top/bottom labels.