whichfirst
Which barrier came first? Sometimes the data cannot say — and your labels are quietly guessing.
止盈和止损同时被碰到,谁先发生?数据常常回答不了 —— 而你的标签正在悄悄猜。
The 60-second version
If you train models on triple-barrier labels — take-profit, stop-loss, time limit — some of your labels are not measurements. They are assumptions.
Here is one bar of daily data:
open 100 high 106 low 94 close 99 target 105 stop 95
The high cleared your target. The low broke your stop. Both happened. A daily bar records the extremes, not their order, so it cannot tell you which came first. Up-then-down is a win. Down-then-up is a loss.
Most labelling code returns a single label here. It has to invent one.
whichfirst returns the truth instead:
from whichfirst import OHLCBar, audit_first_passage
audit_first_passage([OHLCBar(open=100, high=106, low=94, close=99)],
upper=105, lower=95)
# labels={-1, 1} status='open_aware_dual_hit' ← both remain possible
Point it at a whole training set and it tells you how much of your target is invented:
2,998 events, horizon=10 sessions.
evaluated 2,998
unidentified 449 (14.98% of evaluated) ← label cannot be recovered from these bars
One label in seven, on a real dataset, with commonly used settings.
一分钟看懂
如果你用三重障碍法(止盈、止损、时间限制)给数据打标签做机器学习, 那么你的一部分标签不是"测出来的",是"假设出来的"。
看这一根日线:
开盘 100 最高 106 最低 94 收盘 99 止盈 105 止损 95
最高 106 碰到了止盈,最低 94 跌破了止损 —— 两件都发生了。 但日线只记录极值、不记录顺序,所以它无法告诉你谁先到。 先涨后跌是赚,先跌后涨是亏。
大多数打标代码在这里会返回一个标签。它只能编一个。
whichfirst 返回真相:
from whichfirst import OHLCBar, audit_first_passage
audit_first_passage([OHLCBar(open=100, high=106, low=94, close=99)],
upper=105, lower=95)
# labels={-1, 1} status='open_aware_dual_hit' ← 两种都可能
对整个训练集跑一遍,它会告诉你有多少标签是编造的:
2,998 events, horizon=10 sessions.
evaluated 2,998
unidentified 449 (14.98% of evaluated) ← 这些标签无法从K线恢复
真实数据、常用参数下,七个标签里有一个是编的。
Why this matters more than it sounds · 为什么这比听起来严重
Label noise is not like feature noise. A wrong feature adds variance. A wrong label teaches the model the opposite of the truth, and it does so systematically.
标签噪声和特征噪声不是一回事。特征错了只是增加方差;标签错了是在教模型 反过来的结论,而且是系统性的。
| English | 中文 | |
|---|---|---|
| Not random | Ambiguous bars are the volatile ones. Your model learns its worst lessons exactly where the stakes are highest. | 歧义集中在高波动样本 —— 模型恰好在最要命的地方学到最错的东西 |
| One-directional | Every library resolves ties the same way every time. That is systematic bias, not noise that averages out. | 每个库的 tie 约定都固定不变 ⇒ 系统性偏差,不是能被平均掉的噪声 |
| Invisible | No exception, no warning, no NaN. Your accuracy is computed against the invented labels, so it looks fine. | 不报错、不告警、不产 NaN。准确率是拿编造的标签算的,所以看起来很正常 |
| Survives validation | Train, validation and test splits all inherit the same convention. Cross-validation cannot see a bias shared by every fold. | 训练/验证/测试集继承同一约定 ⇒ 交叉验证看不见所有折共享的偏差 |
That last row is the reason this package exists. You cannot detect this problem with better modelling. It has to be checked at the data layer, before training.
最后一行是这个包存在的理由:这个问题无法靠"改进模型"发现, 只能在训练之前、在数据层检查。
How much of your data is affected · 影响面有多大
Prevalence is governed almost entirely by barrier width. Measured on a China
A-share panel, 10-session horizon, barriers at k × a 20-day volatility estimate
(full grid in results/daily_dual_hit_sensitivity.csv):
影响面几乎完全由障碍宽度决定。以下在 A 股面板上实测,持有 10 个交易日,
障碍设为 20 日波动率的 k 倍:
| Barrier width · 障碍宽度 | Unidentified · 不可识别比例 |
|---|---|
| 0.5 σ | 17.2 % |
| 1.0 σ | 2.7 % |
| 2.0 σ | 0.2 % |
Narrow barriers are what short holding periods require. The tighter your strategy, the more of your training set is fabricated.
窄障碍正是短持有期策略需要的。你的策略越紧,训练集里编造的部分越多。
Independently reproduced on a separate sample period (2024–2026): 16.6 % / 2.7 % / 0.2 % — the same three numbers. (换样本期 2024–2026 独立复现:16.6% / 2.7% / 0.2%,三个数字一致。)
Install · 安装
pip install whichfirst
From source · 从源码:
git clone https://github.com/val1813/whichfirst
cd whichfirst
pip install -e .
The core primitive imports nothing outside the standard library. pandas is
required only by the batch layer.
(核心原语只用标准库,pandas 仅批量层需要。)
Quick start · 快速上手
1. One event · 单个样本
from whichfirst import OHLCBar, audit_first_passage
bars = [OHLCBar(open=100, high=106, low=94, close=99)]
result = audit_first_passage(bars, upper=105, lower=95)
result.labels # frozenset({-1, 1})
result.is_singleton # False ← this label is not trustworthy · 这个标签不可信
result.status # 'open_aware_dual_hit'
2. A whole training set · 整个训练集
Generate the two input templates · 生成两张输入模板:
python -c "from whichfirst import write_template; write_template('.')"
This writes events_template.csv and bars_template.csv. Fill them in with your
own rows and save them as events.csv and bars.csv — the names used below.
(写出的是 events_template.csv 与 bars_template.csv,填好后另存为下面用到的
events.csv 和 bars.csv。)
events.csv — one row per labelled event. Barriers are absolute prices.
(一行一个样本,障碍填绝对价格。)
symbol,entry_date,upper,lower
600030,2026-08-03,29.00,27.00
000001,2026-08-03,12.00,11.20
bars.csv — the OHLC panel your labels come from.(你的行情面板。)
symbol,date,open,high,low,close
600030,2026-08-03,28.10,28.30,27.90,28.00
600030,2026-08-04,28.05,29.20,27.80,29.10
from whichfirst import audit_events, load_events_csv, load_bars_csv
report = audit_events(load_events_csv("events.csv"),
load_bars_csv("bars.csv"),
horizon=10)
print(report.summary())
report.unidentified.to_csv("labels_to_fix.csv", index=False)
Use the two loaders rather than bare pd.read_csv: they force symbol to text,
so a zero-padded ticker such as 000001 is not read as the integer 1. That
corruption fails as no_entry_bar on every affected row, which looks like
missing data rather than a dtype problem.
请用这两个 loader 而不是裸 pd.read_csv —— 它们强制 symbol 为文本,
否则 000001 会被读成整数 1,与行情面板永远对不上,
而报错是 no_entry_bar(看起来像"数据缺失",实际是类型问题)。
symbol entry_date labels label identified status
600030 2026-08-03 [1] 1 True upper_only
000001 2026-08-03 [-1, 1] <NA> False open_aware_dual_hit
3. What to do with the flagged rows · 拿到名单后怎么处置
The audit does not decide for you.(工具不替你决定。)
| Option · 选项 | Trade-off · 代价 |
|---|---|
| Drop them · 丢掉 | Costs sample size. Assumes nothing. · 损失样本量,但不做任何假设 |
| Re-resolve from intraday data · 用分钟线补判 | The only option that actually recovers the label · 唯一真正能恢复标签的做法。See tick_truth_one_day.py |
| Keep with reduced weight · 降权保留 | Treats the label as soft · 当作软标签,不丢信息 |
Why the answer is correct, not just plausible · 为什么这个答案是对的
This is not a heuristic. The label set is sharp: exactly the labels consistent with the recorded bar, no more and no fewer. (这不是启发式规则。返回的标签集是紧的 —— 恰好是与该 K 线相容的全部标签。)
A label is uniquely identified when · 标签唯一可识别的两种情形:
- the first barrier-touching bar reaches only one barrier — or 首个触及障碍的 K 线只碰到一侧;或
- that bar opens already beyond one barrier. The open is monitored before the extremes, so the first touch is pinned. This second case recovers labels that a high/low-only check throws away. 该 K 线开盘价已越过一侧 —— 开盘先于极值被观察到,所以首次触及是确定的。 这第二种情形能免费救回一批"只看最高/最低"会丢掉的标签。
A label is set-valued only when that bar reaches both barriers and opens strictly between them. Then both orderings are genuinely consistent with the data, and no tie convention recovers the truth — it can only assert it. (只有当该 K 线同时碰到两侧、且开盘价严格在两者之间时,标签才是集合值。 此时两种顺序都与数据相容,任何 tie 约定都不是"恢复"真相,而是"断言"。)
Three properties follow, each enforced by a test (三条性质,每条都有测试强制):
- No silent substitution. A dual hit never collapses to a single label. 双碰绝不塌缩成单一标签。
- No silent dropping. Rows that cannot be evaluated are reported with a
reason (
no_entry_bar,no_future_bars,invalid_barriers,invalid_ohlc) — a shorter report must never read as "everything else is fine". 无法评估的行会带原因报出 —— 报告变短绝不能读成"其余都没问题"。 - No diluted statistics. Unevaluable rows are excluded from the prevalence denominator, so skips cannot make your data look cleaner than it is. 无法评估的行不进分母,所以 skip 不会让数据看起来比实际干净。
Positioned against the literature · 文献定位
The literature audit records 15 full-text-verified papers using triple-barrier labelling:
- 10 of 15 never state which bar field triggers a barrier. 15 篇里有 10 篇从未说明哪个字段触发障碍。
- 13 of 15 never state how a same-bar dual touch is handled. 15 篇里有 13 篇从未说明同一根 K 线双碰怎么处理。
Prior work is cited as foundation, not competition: López de Prado (2018, AFML Ch. 3) for the canonical target; Broadie–Glasserman–Kou (1997) for the continuous-versus-discrete monitoring distinction; the Manski partial-identification tradition for set-valued targets; Kang & Kim (2025) and Jadouli (2026) as the closest precursors on daily OHLC.
What this package does not claim · 本包不主张什么
Stated plainly, because these limits decide whether the tool is right for you (直说,因为这些边界决定这工具对你是否合适):
- Not an alpha claim. The frozen classifier comparison in this repository finds lower macro-F1 for the corrected-label model. Identifying your target is a correctness result, not a performance one. 不是收益主张。 本仓库的冻结对照实验发现,修正标签后的模型 macro-F1 反而更低。识别标签是正确性结果,不是性能结果。
- It does not recover the order. It proves when the order is unrecoverable from the bars you supplied. Only finer data can resolve it. 它不恢复顺序,只证明顺序在你给的 K 线下不可恢复。只有更细的数据能解决。
- The tie is not a coin flip. The ordering asymmetry is rejected on an A-share session and not replicated on a Binance window — the divergence is reported as unexplained rather than explained away. 双碰不是抛硬币。 顺序对称性在 A 股被拒绝,在 Binance 窗口未复现 —— 这个分歧被如实报为"未解释",而不是被圆过去。
- Market-agnostic but convention-dependent. The core logic assumes only ordered OHLC bars. Session definitions and overnight gaps are yours to declare. 不绑市场,但依赖口径。 核心逻辑只假设有序 OHLC;交易时段定义与隔夜跳空由你声明。
A full section of results that did not come out as hoped — including a preregistered threshold that was not met — is kept in REPRODUCTION.md, because a reader needs them to judge the rest. (一整节"不如预期的结果",包括一个未达成的预注册门槛,保留在 REPRODUCTION.md 里 —— 读者需要它们才能判断其余部分。)
Design principles · 设计原则
The core has no dependencies. label_audit.py is 66 lines
and imports only dataclasses and typing. Identification logic you cannot read
in one sitting is logic you cannot audit.
(核心零依赖:66 行,只 import dataclasses 和 typing。
一次读不完的识别逻辑,就是无法审计的逻辑。)
Barriers are absolute prices, never multiples. The batch layer refuses to infer a volatility convention on your behalf, because barrier width is the single parameter prevalence is most sensitive to — 0.5 σ and 2.0 σ differ by two orders of magnitude. Guessing it would mean choosing your result for you. (障碍只收绝对价格:障碍宽度恰恰是影响面最敏感的参数,0.5σ 与 2.0σ 差两个 数量级。替你猜一个波动率约定,等于替你选定结论。)
Refusal is a feature. Every path that cannot produce a trustworthy answer returns a reason instead of a number. (拒绝是特性:任何无法给出可信答案的路径,都返回原因而不是数字。)
Verify everything yourself · 自己验证
python -m pytest -q # 50 tests, no data access required
sha256sum -c SHA256SUMS.txt # 114 files, byte-level integrity
python build_exhibits.py # regenerates every figure and table
Every exhibit is generated from frozen result JSON — no number in the manuscript is hand-authored. The public-data chain replicates against official Binance archives, each file checksum-verified before use, including a zero-shared-code independent verifier.
(每张图表都由冻结的结果 JSON 生成,稿件里没有手写的数字。公开数据链对 Binance 官方归档复现,每个文件使用前校验,并含一个 零共享代码的独立验证器。)
| Path | Contents |
|---|---|
label_audit.py |
The audit primitive — start here · 审计原语,从这里开始 |
batch_audit.py |
Training-set audit, templates, reporting · 批量审计与模板 |
whichfirst.py |
Public API · 公开接口 |
results/ |
Frozen outputs (JSON + CSV) · 冻结结果 |
exhibits/ |
Generated figures and tables · 生成的图表 |
submission/ |
Manuscript, frozen specs, audit records · 稿件与冻结规格 |
Citation · 引用
@software{whichfirst,
title = {whichfirst: identifying first-passage triple-barrier labels from OHLC data},
author = {Huang, Zhongchang},
year = {2026},
url = {https://github.com/val1813/whichfirst}
}
Machine-readable metadata in CITATION.cff.
License · 许可
Code under MIT. Derived tables, figures and documentation under CC BY 4.0.
Metadata
Release files for whichfirst 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| whichfirst-0.1.0.tar.gz | 26.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| whichfirst-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 44.3 kB
Release files / whichfirst-0.1.0.tar.gz
| Download URL | whichfirst-0.1.0.tar.gz |
|---|---|
| Size | 26.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ca9a44ffc064bf6ca44e99bb19dd68ee196a819a1bc680e1ca673875cbbac454
|
|
BLAKE2b-256 checksum How to use checksums |
350ebf5667f7307b621e0d5af819481d227a207a0a420635f6a52f5d7a9ee5b0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.10
|
Release files / whichfirst-0.1.0-py3-none-any.whl
| Download URL | whichfirst-0.1.0-py3-none-any.whl |
|---|---|
| Size | 18.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
23c03123b02f72b4852cd5ef059a4c13a428910d775b01d318d3fd665680323f
|
|
BLAKE2b-256 checksum How to use checksums |
12bb8617d6cdc9a1249484494f1d858e67e8a6570ff6d92290ba61f03aaffc5b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.10
|