Skip to main content

whichfirst

Which barrier came first? Sometimes the data cannot say — and your labels are quietly guessing.

止盈和止损同时被碰到,谁先发生?数据常常回答不了 —— 而你的标签正在悄悄猜。

tests python license core deps pip

English · 中文


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 · 标签唯一可识别的两种情形:

  1. the first barrier-touching bar reaches only one barrier — or 首个触及障碍的 K 线只碰到一侧;或
  2. 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)

Source distribution for whichfirst 0.1.0
File Size Uploaded
whichfirst-0.1.0.tar.gz 26.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for whichfirst 0.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page