Skip to main content

bar-forge

CI Coverage Types Mutation Python License

Build market data bars on an activity clock instead of a wall clock, and normalise the resulting features without leaking the future.

Why this exists

A one-minute bar at 09:31 and a one-minute bar at 13:47 are not comparable observations: one may contain a thousand trades and the other five. Feeding both to a model as equally weighted rows is the failure mode this library prevents. Clock sampling produces returns with fat tails, volatility clustering and serial dependence, which breaks the independence assumptions that sit underneath most model fitting and cross-validation. Sampling on traded activity instead restores much of that structure, and it costs a few lines of code rather than a new model.

The second half of the library is the other half of the same problem. Once bars are built, features have to be put on a common scale before instruments can share a model, and every rolling transform is an opportunity to accidentally use tomorrow's data. Every transform in bar_forge.normalize is strictly causal, and the property is asserted directly rather than assumed.

Install

python -m pip install bar-forge

For editable development, clone the repository and install the development dependencies:

python -m pip install -e ".[dev]"

Python 3.11 or newer. The only runtime dependency is numpy.

Quickstart

Compare every bar type on one synthetic trade stream, each calibrated to produce a comparable number of bars:

bar-forge compare --n-trades 200000 --seed 7
bar-forge compare: 200000 synthetic trades, seed 7, target 500 bars per type

          bar type   threshold   bars   mean ret   std ret  exc kurt  |ac(1)|          JB
-----------------------------------------------------------------------------------------
time                     125.2    500  +2.17e-04 1.504e-02     2.401   0.0100       120.1
tick                       400    500  +1.70e-04 1.498e-02     0.438   0.0230         4.0
volume                3.61e+04    498  +1.66e-04 1.451e-02    -0.143   0.0573         0.4
dollar               3.203e+06    497  +1.85e-04 1.464e-02    -0.117   0.0381         5.0
tick imbalance              21    489  +1.01e-04 1.391e-02    -0.105   0.0834         2.7
volume imbalance          3072    497  +1.35e-04 1.477e-02     0.338   0.0120         3.4

Lowest Jarque-Bera: volume bars at 0.4 versus 120.1 for time bars (283.9x).

Read the exc kurt and JB columns. On the same 200,000 trades, sampled to the same number of bars, clock time leaves excess kurtosis at 2.40 and a Jarque-Bera statistic of 120.1; sampling on volume brings those to -0.14 and 0.4. Nothing about the price process changed, only where the bars were cut. Add --json for machine-readable output.

Using the library directly:

from bar_forge import atr_normalize, dollar_bars, generate_trades, tick_rule_signs

trades = generate_trades(50_000, seed=7)
bars = dollar_bars(trades, dollars_per_bar=1_000_000.0)

print(f"{len(trades)} trades -> {len(bars)} dollar bars")
print(f"first bar: close={bars[0].close:.2f} vwap={bars[0].vwap:.4f} ticks={bars[0].trade_count}")

signed = atr_normalize(bars, window=20)
print(f"ATR-normalised move at bar 40: {signed[40]:+.4f} average true ranges")

signs = tick_rule_signs(trades)
print(f"buy-initiated share under the tick rule: {sum(s > 0 for s in signs) / len(signs):.3f}")
50000 trades -> 383 dollar bars
first bar: close=98.77 vwap=99.0473 ticks=88
ATR-normalised move at bar 40: -0.7173 average true ranges
buy-initiated share under the tick rule: 0.497

Run the test suite, including the causality proofs:

python -m pytest -q

How it works

flowchart LR; T[trades] --> C{clock}; C -->|ticks / volume / dollars| A[accumulators]; C -->|signed flow| I[imbalance threshold]; A --> B[bars OHLCV]; I --> B; B --> N[strictly causal normalization]; N --> F[comparable features]

Bar construction

Every constructor in bar_forge.bars consumes an iterable of Trade(timestamp, price, size) and returns a list of frozen Bar records carrying start_time, end_time, open, high, low, close, volume, notional, trade_count and vwap. They differ only in what closes a bar.

Constructor Bar closes when
time_bars(trades, interval) the clock crosses a grid boundary at floor(timestamp / interval)
tick_bars(trades, n_ticks) n_ticks trades have arrived
volume_bars(trades, volume_per_bar) cumulative size reaches volume_per_bar
dollar_bars(trades, dollars_per_bar) cumulative price * size reaches dollars_per_bar
tick_imbalance_bars(trades, expected_imbalance) abs(sum(sign)) reaches the threshold
volume_imbalance_bars(trades, expected_imbalance) abs(sum(sign * size)) reaches the threshold

The trade that trips a threshold is kept whole rather than split, so volume and dollar bars overshoot slightly. Time bars are anchored at zero on the trade clock rather than at the first trade, so two instruments sampled with the same interval share boundaries; an interval with no trades produces no bar rather than an empty one.

Why dollar bars. Tick bars ignore trade size, so they are sensitive to how a broker slices an order: the same institutional print chopped into ten child orders produces ten times the sampling events. Volume bars fix that but count quantity, which is not a stable unit over time. If a stock doubles in price and volume halves, volume bars sample half as often for exactly the same economic activity, and a 2-for-1 split mechanically doubles the sampling rate overnight. Dollar bars count money changing hands. Notional is invariant to how quantity is redefined, so the sampling rate tracks economic participation across price-level drift, splits and long histories. That is why dollar bars are the sensible default when you want one threshold to work over years of data.

The tick rule. Imbalance bars need a side for each trade, and public trade feeds often do not carry one. The tick rule infers it from price change alone:

  • price > previous price gives +1, buyer-initiated
  • price < previous price gives -1, seller-initiated
  • price == previous price carries the previous sign forward
  • the first trade of a stream has no predecessor and is signed +1 by convention

The carry-forward on a zero tick is the part that matters. On a tick-size grid a large share of prints trade at the previous price, and signing those as zero would discard most of the flow. tick_rule_signs exposes the signs directly. Tick-rule state carries across bar boundaries, because it is a property of the trade stream rather than of a bar.

The trailing bar. Every constructor takes include_partial: bool = False. The leftover accumulation at the end of a stream is a bar whose sampling condition never fired. It is returned only when you ask for it, and it is never silently dropped from a count or silently mixed in with complete bars. For time bars the final clock interval is always treated as partial, because a stream that ends mid-interval is indistinguishable from one that ends on a boundary.

Causal normalisation

bar_forge.normalize maps bar features onto a comparable scale. Every function returns an array the same length as its input, with nan wherever the trailing window is not yet full, so a partially populated estimate cannot masquerade as a real one.

  • zscore(values, window) subtracts the mean and divides by the sample standard deviation of the window ending at and including t.
  • rank_normalize(values, window) returns the tie-averaged percentile rank of values[t] within that window, in [0, 1]. Magnitude is discarded, which is what you want when instruments have incomparable tails.
  • volume_normalize(bars) divides each bar's log return by that bar's volume.
  • atr_normalize(bars, window) divides each bar's price change by the average true range of the window bars before it. Numerator and denominator are both in price units, so the result is dimensionless: 1.0 means a one-ATR move, comparable across any two instruments regardless of price level or tick size.

The current bar's own true range is deliberately excluded from its denominator. Including it shrinks exactly the observations you care about, and it is the most common way a harmless-looking volatility normalisation quietly eats its own signal.

The causality property is not documentation, it is a test. tests/test_causality.py asserts, for every transform and many prefix lengths, that f(x)[:k] is bit-identical to f(x[:k]), and separately that replacing everything after k with values from a completely different distribution leaves the first k outputs untouched. The same file runs a centred z-score through the same assertions and confirms that it fails, so the test is known to have teeth.

Measurement

bar_statistics(bars) reports bar count, mean and standard deviation of close-to-close log returns, excess kurtosis, absolute lag-1 autocorrelation, and the Jarque-Bera statistic, all implemented directly on numpy. Excess kurtosis and Jarque-Bera are reported as raw statistics. They are diagnostics for how far a sample sits from Gaussian, not hypothesis tests, and no p-value is attached to them.

generate_trades(n, seed, ...) produces the reproducible synthetic stream used by the CLI and the tests: a driftless random-walk mid rounded to a tick grid, Poisson arrivals, lognormal sizes, and activity bursts during which arrival rate, size and volatility all jump together. That last feature is what makes activity bars visibly diverge from time bars. Identical seeds give identical streams.

Verification

Mutation testing

From the repository root, reproduce the mutation run with:

source .venv/bin/activate
mutmut run
mutmut results

The completed run generated 1,208 mutants and killed 1,123 (92.96%). The 85 remaining survivors were reviewed individually and are behavior-equivalent under the public contract, rather than missed mutants:

Equivalent rationale Count
NumPy dtype and fill-value identities 38
zip(strict=...) changes over internally guaranteed equal-length inputs 9
Algebraically symmetric signed-imbalance comparisons 2
Sentinel, default-value, and runtime cast identities 20
Non-contractual diagnostic formatting differences 16
Total reviewed equivalents 85

There were zero suspicious mutants and zero timeouts.

Limitations

  • Imbalance bars use a fixed threshold rather than a running forecast of expected imbalance. This keeps the transform stateless and reproducible, but the threshold has to be recalibrated per instrument and per regime. The CLI shows one way to do it, by bisecting on the realised bar count.
  • The tick rule is a heuristic. Where the feed carries a real aggressor flag, use it; the tick rule misclassifies trades inside the spread and at price reversals.
  • volume_normalize still carries the instrument's volume unit and is not comparable across instruments on its own. Compose it with zscore or rank_normalize.
  • Bars overshoot their threshold by up to one trade, because trades are not split. With thresholds much larger than a typical trade this is negligible; with small thresholds it is not.
  • rank_normalize materialises a window matrix and uses O(len(values) * window) memory.
  • Constructors return lists rather than streaming generators, so a full stream is held in memory. Chunk long histories yourself.
  • The synthetic generator is a deliberately simple model. It is there so examples and tests run offline and deterministically, not to reproduce real microstructure.

Non-goals

  • No strategy logic. No signals, no position sizing, no execution, no backtester. This library builds bars and features and stops there.
  • No data acquisition. No vendor clients, no downloads, no caching layer. Bring your own trades.
  • No multiple-testing machinery. No probability of backtest overfitting, no deflated performance statistics, no p-value corrections. Validation belongs in held-out data across instruments and periods, not in an adjustment applied to an in-sample number.
  • No pandas or scipy dependency. Plain sequences and numpy arrays in, numpy arrays out. Converting to a DataFrame at the boundary is one line and is your choice, not this library's.
  • No inferred labels or targets. Triple-barrier labelling, meta-labelling and sample weighting are downstream concerns and out of scope.

License

MIT. See LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

bar_forge-1.0.0.tar.gz (39.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

bar_forge-1.0.0-py3-none-any.whl (24.8 kB view details)

Uploaded Python 3

File details

Details for the file bar_forge-1.0.0.tar.gz.

File metadata

  • Download URL: bar_forge-1.0.0.tar.gz
  • Upload date:
  • Size: 39.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.11.12

File hashes

Hashes for bar_forge-1.0.0.tar.gz
Algorithm Hash digest
SHA256 043c5794d42b2d4598a3e66a8d130e3b34772eae89cddaeca68397491c56ecd5
MD5 9f07db38d431745e6769938525afb774
BLAKE2b-256 c889e6b598d39b51f82ad26375e89718e1236679e6fce9787908e98ea968ac0f

See more details on using hashes here.

File details

Details for the file bar_forge-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: bar_forge-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 24.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.11.12

File hashes

Hashes for bar_forge-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 83d3dcf66cd8eb627a940d582e409213c7c6f6d969b3f27d0c68f7f6f8f4aac2
MD5 a82b333ff9146caddc05e0c6e5bf8beb
BLAKE2b-256 2fc698acc71df6dec341e5a3947bd4a9b03e620b793551d59aa2bd81d99a60d1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page