labeling
The usual way to label financial data is to look a fixed number of bars ahead and take the sign of the return. AFML §3.2 lists what is wrong with that. The same 1% threshold is a large move in a quiet market and noise in a turbulent one; and the label ignores the path, so a position that would have been stopped out on day two is labelled a winner because the price recovered by day five. No trader holds through a stop they have set.
The triple-barrier method (§3.4) labels an observation by which of three barriers the price reaches first: a profit target above, a stop below, or a time limit. The two horizontal barriers are set in multiples of a volatility estimate taken at the event, so “a large move” means the same thing in every regime.
How an event is resolved
Section titled “How an event is resolved”For an event at bar with target , profit multiple , stop multiple and side (taken as when no side is given), the path return and the first touch are
and the label is . The details that decide edge cases:
- Returns are simple, , measured from the event bar’s close.
- A barrier is touched when the return goes strictly beyond it, as in Snippet 3.2. Sitting exactly on a barrier is not a touch.
- A multiple of zero disables that barrier.
pt_sl = (0.0, 1.5)is a stop and a time limit with no profit target. - The label is the sign of the return at , including at the vertical barrier. This is AFML’s Snippet 3.5. It is not the variant, common elsewhere, that assigns 0 when time runs out. A label of 0 appears only when the return is exactly zero.
- Events are dropped, not defaulted. An event whose target is NaN or not above
min_retis discarded. An event with no vertical barrier that never touches a horizontal one has no outcome yet; it is kept in the events with an empty end time and produces no label. That includes an event on the last bar, which has no later bar to touch. - Only closes are checked. A bar whose high pierced the target and whose close did not does not count as a touch. On coarse bars this understates how often stops are hit.
The whole path, on data
Section titled “The whole path, on data”import mathimport randomfrom collections import Counterfrom datetime import datetime, timedelta
from openquant import filters, labeling, volatility
# 90 days of hourly closes.rng = random.Random(3)start, price, stamps, close = datetime(2024, 1, 1), 100.0, [], []for i in range(24 * 90): price *= math.exp(rng.gauss(0, 0.004)) stamps.append((start + timedelta(hours=i)).strftime("%Y-%m-%d %H:%M:%S")) close.append(price)
# 1. The unit of risk: an EWMA of daily returns (Snippet 3.1). The first row is NaN.vol = [(t, v) for t, v in volatility.get_daily_vol(stamps, close, 100) if not math.isnan(v)]vol_stamps, vol_values = [t for t, _ in vol], [v for _, v in vol]
# 2. Where to look: CUSUM events at roughly one day's volatility.events = filters.cusum_filter_timestamps(close, stamps, 0.02)
# 3. How long to wait: a vertical barrier two days after each event.vertical = labeling.add_vertical_barrier(events, stamps, num_days=2)
# 4. Barriers at 1.5 daily vols either side; ignore events whose target is under 0.5%.found = labeling.get_events(stamps, close, events, (1.5, 1.5), vol_stamps, vol_values, 0.005, vertical_barrier_times=vertical)bins = labeling.get_bins(found, stamps, close)print(f"{len(events)} events -> {len(found)} with a usable target -> {len(bins)} labels")print("labels:", dict(sorted(Counter(b[3] for b in bins).items())))
# Which barrier ended each event? A return beyond 1.5 targets means a horizontal one did.def exit_of(ret, target): return "profit" if ret > 1.5 * target else "stop" if ret < -1.5 * target else "time"
for kind in ("profit", "stop", "time"): rows = [b for b in bins if exit_of(b[1], b[2]) == kind] ups = sum(b[3] == 1 for b in rows) size = sum(abs(b[1]) / b[2] for b in rows) / len(rows) print(f"{kind:6s} {len(rows):3d} events, {ups:2d} labelled +1, mean |return| = {size:.2f} targets")
# Meta-labeling: give each event a side (here: follow the last day's move) and ask only# whether acting on it paid. Labels become {0, 1}; returns are signed by the side.index = {t: i for i, t in enumerate(stamps)}sides = [1.0 if close[index[t]] > close[index[t] - 24] else -1.0 for t in events]meta = labeling.meta_labels(stamps, close, events, vol_stamps, vol_values, list(zip(events, sides)), pt=1.5, sl=1.5, min_ret=0.005, vertical_barrier_times=vertical)print("meta-labels:", dict(sorted(Counter(m[3] for m in meta).items())))111 events -> 109 with a usable target -> 108 labelslabels: {-1: 52, 1: 56}profit 35 events, 35 labelled +1, mean |return| = 1.67 targetsstop 32 events, 0 labelled +1, mean |return| = 1.64 targetstime 41 events, 21 labelled +1, mean |return| = 0.54 targetsmeta-labels: {0: 54, 1: 54}Read the three exit rows. Every profit exit is labelled +1 and every stop exit −1, by
construction, and they ended about 1.65 targets away from the entry. The 41 events that ran
out of time are split 21 to 20 and ended, on average, half a target from where they started:
their labels are close to coin flips, and they are 38% of the training set. That is
the price of taking the sign at the vertical barrier. The remedies are to give those
observations less weight (weight by absolute return — see
sample-weights), to widen the time limit, or to relabel them 0
yourself and train a three-class model.
The meta-labels come out 54 to 54 because the side rule — follow yesterday’s move — has no edge on a random walk, which is what this series is. On real data that ratio is the base rate the secondary model has to beat.
Meta-labeling
Section titled “Meta-labeling”Give get_events a side for each event and the question changes from “which way?” to “was
acting on that call worth it?” (§3.6). The return is multiplied by the side, the barriers
become the side’s profit target and stop, and the label is 1 if the signed return is positive
and 0 otherwise. A primary model — a rule, a discretionary signal, another classifier — sets
the side; a secondary model learns from the {0, 1} labels when to act on it, and its
probability sizes the bet (bet-sizing). The primary model can then
be tuned for recall, because the secondary one is there to restore precision.
meta_labels is triple_barrier_labels restricted to events that carry a side.
Choosing the parameters
Section titled “Choosing the parameters”- Target.
get_daily_volis Snippet 3.1: an exponentially weighted standard deviation of one-day returns. Its first value is NaN and early values rest on few observations, so expect to lose the first events. Any positive series aligned to the event timestamps will do; what matters is that it is known at the event. - Multiples. Symmetric barriers make the label a statement about direction. Asymmetric
ones build a payoff ratio into the label, and the class balance shifts with them — check it
before training.
pt_slis(profit, stop)in that order. min_ret. Events whose target is tiny produce barriers inside the bid-ask spread and labels that cost more to trade than they are worth.- Time limit.
add_vertical_barrierlooks up the first bar at or after the event time plus the offset. An event too close to the end of the series to have one gets no vertical barrier at all, rather than a shortened one. From Python the offset isnum_days,num_hours,num_minutesandnum_seconds, each defaulting to 0 and summed; leaving all four at 0 is aValueError, since a zero offset puts the barrier on the event itself. - Rare classes.
drop_labels(bins, min_pct)removes the rarest class while it holds no more thanmin_pctof the observations and at least three classes remain (Snippet 3.8).
From Rust
Section titled “From Rust”use chrono::{Duration, NaiveDate};use openquant::labeling::{add_vertical_barrier, get_bins, get_events};
let t0 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap();let day = |d: i64| t0 + Duration::days(d);let prices = [100.0, 100.5, 101.0, 103.5, 102.0, 101.0, 100.0, 99.0];let close: Vec<_> = prices.iter().enumerate().map(|(i, p)| (day(i as i64), *p)).collect();
// One event on day 0; a constant 2% target; barriers one target wide; a five-day limit.let events = vec![day(0)];let target = vec![(day(0), 0.02)];let vertical = add_vertical_barrier(&events, &close, 5, 0, 0, 0);let found = get_events(&close, &events, (1.0, 1.0), &target, 0.0, 1, Some(&vertical), None);let bins = get_bins(&found, &close);
// +1.0% on day 2 is inside the barrier; +3.5% on day 3 is beyond it.assert_eq!(found[0].1.t1, Some(day(3)));let (_, ret, _, label, side) = bins[0];assert!((ret - 0.035).abs() < 1e-12);assert_eq!((label, side), (1, None));What to watch for
Section titled “What to watch for”- Labels overlap. An event’s label depends on prices up to , and events fired close
together share most of that window. The observations are not independent, and an ordinary
K-fold split leaks. Measure the overlap with
sampling, weight withsample-weights, and split withcross-validation, passing each event’s . - Timestamps must match exactly. Events, targets and sides are joined to
closeby timestamp. An event whose timestamp is not a bar incloseis skipped without an error. - Timestamps from Python are strings. They cross the binding as
"%Y-%m-%d %H:%M:%S"with an optional fractional second (up to nanoseconds). Output times are written asstr(datetime)writes them: no fraction on a whole second, otherwise six digits (nine below a microsecond). A string built withstr()comes back unchanged; one written another way, such as"...01.5", comes back as"...01.500000". num_threadsdoes nothing. It is kept so that mlfinlab call sites port unchanged.
Related modules
Section titled “Related modules”filters— where the events come from.util-volatility— the target.sampling,sample-weights,cross-validation— what the overlap between labels requires.bet-sizing— from a meta-label probability to a position.
API reference
Section titled “API reference”Python
Section titled “Python”Signatures, parameters, return values and errors for every function: openquant.labeling.
labeling.add_vertical_barrier— Vertical (time) barriers: the first bar at or after each event time plus an offset.labeling.get_events— mlfinlab-compatible form of `triple_barrier_events` (AFML Snippet 3.6's `getEvents`).labeling.get_bins— mlfinlab-compatible labelling of resolved events (AFML Snippet 3.7's `getBins`).labeling.triple_barrier_events— Triple-barrier events: resolve each event's end time `t1`.labeling.triple_barrier_labels— Triple-barrier labels: the sign of the return from `t0` to the resolved `t1`.labeling.meta_labels— Meta-labels: whether a primary model's side would have made money on each event.labeling.drop_labels— Drop under-represented labels.
Rustdoc for this commit, with every public item of openquant::labeling (after a crates.io release the same pages are on docs.rs):