Skip to content
All library documents

Calibrating Trade Bars Across Multiple Market Sessions

Notebook Machine Learning for Trading

Summary

This study uses ten trading days of NVDA market-by-order data to examine how daily trading activity affects bar sampling. It filters trades to regular US market hours, classifies aggressor sides from venue labels, and reports day-to-day variation in trade counts, share volume, and dollar volume. It then calibrates time, tick, volume, dollar, and imbalance bar thresholds toward a target daily bar count, comparing per-day bar counts and bar statistics to judge parameter stability across sessions.

Trades without an identified aggressor are excluded from the shared comparison set because volume and imbalance bars require side information; their share is reported separately. The study argues that a threshold fitted to one day may not carry reliably to another and uses multi-day diagnostics to guide production calibration. Its evidence is specific to one liquid equity and a short sample of sessions, so recommendations may not transfer unchanged to other symbols or market conditions. Bar type should follow the intended use, such as value-weighted features, order-flow adaptation, activity normalization, or familiar time alignment.

Key ideas

  • Daily variation in trading activity can make thresholds calibrated on one session unstable on another.
  • Compare bar counts and distributional diagnostics across multiple days when calibrating sampling parameters.
  • Volume and imbalance bars need classified aggressor sides, so unclassified trades affect the sample.
  • Choose a bar type according to whether the goal is value weighting, order-flow adaptation, activity normalization, or time alignment.
  • The findings come from a limited sample of NVDA sessions and require validation elsewhere.

Tags

Full text
# DataBento MBO: Multi-Day Bar Calibration Study


# DataBento MBO: Multi-Day Bar Calibration Study

**Chapter 3: Market Microstructure**

**Docker image**: `ml4t`

## Purpose

Calibrate bar-sampling parameters across 10 trading days of NVDA MBO data,
something single-day ITCH samples can't support. The study answers: how
does daily volume variability translate to bar-count instability, and how
robust are imbalance-bar parameters across market conditions?

## Learning Objectives

After completing this notebook, you will be able to:
- Load and process multi-day MBO trade data efficiently with polars
  streaming.
- Quantify day-to-day volume variability and its impact on fixed-threshold
  bar counts.
- Calibrate dollar / volume / imbalance bar thresholds for a target daily
  bar count.
- Recommend production-ready calibration methodology grounded in the
  per-day diagnostics.

## Book reference

Section §3.4, *The Art of Sampling* — multi-day calibration paragraph
referencing fixed-threshold imbalance bars.

## Prerequisites

- DataBento XNAS-ITCH MBO parquets at
  `data/equities/market/microstructure/market_by_order/NVDA/` (10 trading
  days, November 2024).

---

## 1. Setup

```python
"""DataBento MBO: Multi-Day Bar Calibration Study — calibrating bar sampling parameters across trading days."""

import re
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import seaborn as sns
from scipy import stats

# ML4T imports - path resolution
# Import loader for MBO data
from data import load_mbo_data
from utils.paths import get_output_dir
from utils.style import show_with_alt
```

```python
# Production defaults — Papermill injects overrides for CI
N_DAYS = 10  # Number of trading days to analyze
MAX_ROWS_PER_DAY = 0  # 0 = all rows
```

```python
# Style configuration
sns.set_style("whitegrid")

# Polars display config
```

```python
# Directories and configuration
OUTPUT_DIR = get_output_dir(3, "databento")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

SYMBOL = "NVDA"
# Get file paths from the canonical loader (handles legacy/new path resolution)
data_files = load_mbo_data(symbols=[SYMBOL], list_files=True)
SYMBOL_DATA_DIR = data_files[0].parent if data_files else None

# Multi-day configuration
TARGET_BARS_PER_DAY = 500  # ~1 bar per minute of RTH (6.5 hours)
THRESHOLD_LABEL_FMTS = {
    "time": lambda t: f"{int(t)}m",
    "tick": lambda t: f"{int(t):,}",
    "volume": lambda t: f"{int(t / 1000)}K",
    "dollar": lambda t: f"${int(t / 1000)}K",
}

# Normalize MAX_ROWS_PER_DAY: 0 means no limit
if MAX_ROWS_PER_DAY == 0:
    MAX_ROWS_PER_DAY = None
```

## 2. Multi-Day Data Loading

Load trade data from multiple days and prepare for calibration analysis.
We filter to Regular Trading Hours (RTH: 9:30-16:00 ET = 13:30-21:00 UTC).

### Load Single-Day Trades

Parse one parquet file into RTH trades with aggressor side classification.

```python
def _load_day_trades(file_path: Path, max_rows_per_day: int | None = None) -> pl.DataFrame:
    """Load and process trades from a single daily parquet file.

    Filters to Regular Trading Hours (09:30-16:00 America/New_York) and maps the
    aggressor side from DataBento's Trade-record convention.
    """
    # Derive the date from the trailing 8-digit token so both file layouts work:
    # Download Center `xnas-itch-YYYYMMDD.mbo.dbn.parquet` and API `YYYYMMDD.parquet`.
    date_str = re.search(r"(\d{8})", file_path.stem).group(1)
    trade_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"

    # Load raw MBO data
    df = pl.read_parquet(file_path)

    # Normalize the event-time column name (API files use `ts_event`).
    if "timestamp" not in df.columns and "ts_event" in df.columns:
        df = df.rename({"ts_event": "timestamp"})

    # Select columns
    df = df.select(["timestamp", "action", "side", "price", "size"])

    # Cast timestamp (UTC; kept naive for downstream)
    df = df.with_columns(pl.col("timestamp").cast(pl.Datetime("ns")))

    # Filter to trades only (action == 'T')
    df = df.filter(pl.col("action") == "T")

    # Regular trading hours (09:30-16:00 America/New_York). Convert the UTC instant
    # to exchange-local time so the window is correct in both EDT and EST rather
    # than admitting an hour of pre-market as a fixed UTC window does.
    _et = pl.col("timestamp").dt.replace_time_zone("UTC").dt.convert_time_zone("America/New_York")
    df = df.filter(
        ((_et.dt.hour() > 9) | ((_et.dt.hour() == 9) & (_et.dt.minute() >= 30)))
        & (_et.dt.hour() < 16)
    )

    # Add date column
    df = df.with_columns(pl.lit(trade_date).alias("date"))

    # Aggressor side for Trade (T) records: DataBento sets `side` to the trade
    # aggressor — B = buy-initiated (+1), A = sell-initiated (-1), N = unknown (0).
    # (The resting-order interpretation applies to Fill `F` records, not `T`.)
    df = df.with_columns(
        pl.when(pl.col("side") == "B")
        .then(1)
        .when(pl.col("side") == "A")
        .then(-1)
        .otherwise(0)
        .alias("side_num")
    )

    # Select final columns
    df = df.select(
        [
            "timestamp",
            "date",
            pl.col("price"),
            pl.col("size").alias("volume"),
            pl.col("side_num").alias("side"),
        ]
    )

    if max_rows_per_day is not None:
        df = df.head(max_rows_per_day)

    return df
```

### Combine Multi-Day Trades

Discover daily files, load each via the helper, and concatenate into a single DataFrame.

```python
def load_multiday_trades(
    data_dir: Path, n_days: int, max_rows_per_day: int | None = None
) -> pl.DataFrame:
    """Load trade data from multiple days.

    Parameters
    ----------
    data_dir : Path
        Directory containing daily parquet files
    n_days : int
        Number of trading days to load
    max_rows_per_day : int, optional
        Limit rows per day (for testing)

    Returns
    -------
    pl.DataFrame
        Combined trades with columns: timestamp, date (str), price, volume, side
    """
    data_files = sorted(data_dir.glob("*.parquet"))[:n_days]

    if not data_files:
        print(f"No data files found in {data_dir}")
        return pl.DataFrame()

    all_trades = []

    for file_path in data_files:
        df = _load_day_trades(file_path, max_rows_per_day)
        all_trades.append(df)
        # Extract date for logging from the loaded frame
        trade_date = df["date"][0] if len(df) > 0 else file_path.name
        print(f"  {trade_date}: {len(df):,} trades")

    if not all_trades:
        return pl.DataFrame()

    combined = pl.concat(all_trades).sort(["date", "timestamp"])
    print(f"\nTotal: {len(combined):,} trades across {len(all_trades)} days")

    return combined
```

```python
# Load multi-day trade data
print(f"Loading {N_DAYS} days of {SYMBOL} trade data...\n")
trades = load_multiday_trades(SYMBOL_DATA_DIR, n_days=N_DAYS, max_rows_per_day=MAX_ROWS_PER_DAY)

if len(trades) == 0:
    print("No trade data loaded. Check data directory.")
    trades = None
```

### Trades with no aggressor side

DataBento marks a trade `N` when it cannot say which side was the aggressor, and this
notebook maps that to a side of zero. Volume and imbalance bars cannot use such a
trade: the first splits each bar's volume into buys and sells, the second accumulates
signed flow. Tick and dollar bars could use it, and time bars do not look at side
at all.

Every bar type here is compared against the others, though, so they have to be built
from the same trades. The comparison set is therefore the classified trades, and the
share that drops out is printed rather than absorbed - on a liquid name it is not
small, and a reader comparing these bars to a count of the day's prints should know
the difference.

```python
if trades is not None and len(trades) > 0:
    classified = trades.filter(pl.col("side") != 0)
    unclassified = len(trades) - len(classified)
    print(
        f"{unclassified:,} of {len(trades):,} trades carry no aggressor side "
        f"({unclassified / len(trades):.1%}); every bar type below is built from the "
        f"remaining {len(classified):,}."
    )
    trades_all_sides = trades
    trades = classified
```

## 3. Daily Volume Profile

Before calibrating bar thresholds, we need to understand the day-to-day
variability in trading activity. This justifies why single-day calibration
is insufficient.

```python
if trades is not None and len(trades) > 0:
    # Every print of the day, including the ones with no aggressor side: this section
    # describes the day's trading, not the subset the bar samplers can use.
    daily_stats = (
        trades_all_sides.group_by("date")
        .agg(
            [
                pl.len().alias("trade_count"),
                pl.col("volume").sum().alias("total_volume"),
                (pl.col("price") * pl.col("volume")).sum().alias("dollar_volume"),
                pl.col("price").mean().alias("avg_price"),
            ]
        )
        .sort("date")
    )

    # Add derived metrics
    daily_stats = daily_stats.with_columns(
        [
            (pl.col("dollar_volume") / 1e6).alias("dollar_volume_M"),
            (pl.col("total_volume") / 1e6).alias("volume_M"),
        ]
    )

    print("=== Daily Trading Profile ===\n")
    print(daily_stats.select(["date", "trade_count", "volume_M", "dollar_volume_M"]))

    # Compute variability metrics
    cv_trades = daily_stats["trade_count"].std() / daily_stats["trade_count"].mean()
    cv_volume = daily_stats["total_volume"].std() / daily_stats["total_volume"].mean()
    cv_dollar = daily_stats["dollar_volume"].std() / daily_stats["dollar_volume"].mean()

    print("\n=== Day-to-Day Variability (CV = StdDev/Mean) ===")
    print(f"  Trade count CV: {cv_trades:.2%}")
    print(f"  Volume CV:      {cv_volume:.2%}")
    print(f"  Dollar volume CV: {cv_dollar:.2%}")
    widest = max(cv_trades, cv_volume, cv_dollar)
    print(
        f"\nConclusion: the widest of these is {widest:.0%}, so a threshold calibrated on "
        f"one day does not carry to the next."
    )
```

## 4. Bar Samplers

Import the vectorized bar samplers from ml4t.engineer.bars.

```python
# Import bar samplers
from ml4t.engineer.bars import (
    DollarBarSampler,
    ImbalanceBarSampler,
    TickBarSampler,
    TickImbalanceBarSampler,
    VolumeBarSampler,
)

print("Bar samplers imported successfully")
```

## 5. Calibration Functions

Define functions to build bars and compute statistics for calibration.

### Time Bar Construction

Build OHLCV bars by grouping trades into fixed time intervals.

```python
def _build_time_bars(day_trades: pl.DataFrame, threshold: float) -> pl.DataFrame:
    """Aggregate trades into time bars using group_by_dynamic."""
    return (
        day_trades.group_by_dynamic("timestamp", every=f"{int(threshold)}m")
        .agg(
            [
                pl.col("price").first().alias("open"),
                pl.col("price").max().alias("high"),
                pl.col("price").min().alias("low"),
                pl.col("price").last().alias("close"),
                pl.col("volume").sum().alias("volume"),
                pl.len().alias("tick_count"),
            ]
        )
        .filter(pl.col("volume") > 0)
    )
```

### Imbalance Bar Construction

Filter trades to those with known aggressor side and build imbalance bars.

```python
def _build_imbalance_bars(
    day_trades: pl.DataFrame, bar_type: str, threshold: float, alpha: float
) -> pl.DataFrame | None:
    """Build volume or tick imbalance bars from trades with known side.

    Returns None if fewer than 50% of trades have a classified side.
    """
    usable = day_trades.filter(pl.col("side") != 0)
    if len(usable) < len(day_trades) * 0.5:
        return None

    if bar_type == "imbalance":
        # Volume Imbalance Bars (VIBs): θ = Σ b_t × v_t
        sampler = ImbalanceBarSampler(expected_ticks_per_bar=int(threshold), alpha=alpha)
    else:
        # Tick Imbalance Bars (TIBs): θ = Σ b_t
        sampler = TickImbalanceBarSampler(expected_ticks_per_bar=int(threshold), alpha=alpha)

    return sampler.sample(usable)
```

### Build Bars for a Day

Dispatch to the appropriate bar sampler for one trading day.

```python
def build_bars_for_day(
    day_trades: pl.DataFrame,
    bar_type: str,
    threshold: float,
    alpha: float = 0.1,
) -> pl.DataFrame | None:
    """Build bars for a single day with given parameters.

    Parameters
    ----------
    day_trades : pl.DataFrame
        Trades for one day (timestamp, price, volume, side)
    bar_type : str
        One of: 'time', 'tick', 'volume', 'dollar', 'imbalance', 'tick_imbalance'
    threshold : float
        Bar threshold (meaning depends on bar_type)
    alpha : float
        EWMA alpha for imbalance bars

    Returns
    -------
    pl.DataFrame or None
        OHLCV bars or None if insufficient data
    """
    if len(day_trades) < 100:
        return None

    try:
        if bar_type == "time":
            bars = _build_time_bars(day_trades, threshold)
        elif bar_type == "tick":
            sampler = TickBarSampler(ticks_per_bar=int(threshold))
            bars = sampler.sample(day_trades)
        elif bar_type == "volume":
            sampler = VolumeBarSampler(volume_per_bar=threshold)
            bars = sampler.sample(day_trades)
        elif bar_type == "dollar":
            sampler = DollarBarSampler(dollars_per_bar=threshold)
            bars = sampler.sample(day_trades)
        elif bar_type in ("imbalance", "tick_imbalance"):
            bars = _build_imbalance_bars(day_trades, bar_type, threshold, alpha)
            if bars is None:
                return None
        else:
            raise ValueError(f"Unknown bar_type: {bar_type}")

        return bars if len(bars) > 10 else None

    except Exception as e:
        print(f"Error building {bar_type} bars: {e}")
        return None
```

### Compute Bar Statistics

Compute statistical properties (normality, autocorrelation, variance ratio) for bar returns.

```python
def compute_bar_statistics(bars: pl.DataFrame) -> dict | None:
    """Compute statistical properties for bar returns.

    Metrics explained:
    - **Jarque-Bera (JB)**: Tests deviation from normality. Lower = more normal.
      JB=0 is perfectly normal; higher values indicate fat tails/skewness.
      Event-driven bars typically show lower JB than time bars.

    - **Autocorrelation(1)**: Correlation of r(t) with r(t-1). Should be ~0
      for efficient markets. High values suggest predictability or microstructure effects.

    - **Skewness**: Asymmetry of return distribution. 0 = symmetric.
      Negative skew (fat left tail) is common in equity returns.

    - **Kurtosis**: Tail weight (excess kurtosis, 0 = normal). Positive values
      indicate fat tails. Financial returns typically show kurtosis > 0.

    - **Variance Ratio VR(q)**: Var(q-period returns) / (q × Var(1-period returns)).
      Under random walk, VR(q) = 1. VR > 1 = momentum; VR < 1 = mean reversion.

    Returns
    -------
    dict or None
        Dictionary of statistics or None if insufficient data
    """
    if bars is None or len(bars) < 30:
        return None

    closes = bars["close"].to_numpy()
    returns = np.diff(np.log(closes))
    returns = returns[~np.isnan(returns) & ~np.isinf(returns)]

    if len(returns) < 20:
        return None

    # Jarque-Bera test
    jb_stat, jb_pval = stats.jarque_bera(returns)

    # Moments
    skewness = stats.skew(returns)
    kurtosis = stats.kurtosis(returns)  # Excess kurtosis

    # Lag-1 autocorrelation
    autocorr = np.corrcoef(returns[:-1], returns[1:])[0, 1]

    # Variance Ratio VR(5) - tests random walk
    # VR(q) = Var(r_t + ... + r_{t+q-1}) / (q * Var(r_t))
    # Under random walk, VR(q) = 1
    q = 5
    if len(returns) > q * 2:
        # Multi-period returns
        multi_returns = np.array([np.sum(returns[i : i + q]) for i in range(len(returns) - q + 1)])
        var_ratio = np.var(multi_returns) / (q * np.var(returns))
    else:
        var_ratio = np.nan

    return {
        "n_bars": len(bars),
        "jb_stat": jb_stat,
        "jb_pval": jb_pval,
        "skewness": skewness,
        "kurtosis": kurtosis,
        "autocorr": autocorr,
        "var_ratio": var_ratio,
        "mean_ret": np.mean(returns) * 100,
        "std_ret": np.std(returns) * 100,
    }
```

## 6. Calibration Experiment

Run the calibration experiment across all days and bar types.
We test multiple thresholds to find those producing ~500 bars/day.

The grid below is a range around the threshold each sampler would need to produce a
few hundred bars a day on a name of this size. Sweeping a range rather than picking
one value is what makes the sensitivity visible: how far the bar count moves for a
given change in threshold is as much a property of the sampler as the count itself.

```python
CALIBRATION_GRID = {
    "time": [1, 2, 5, 10],  # minutes
    "tick": [500, 1000, 2000, 4000],
    "volume": [25_000, 50_000, 100_000, 200_000],
    "dollar": [5_000_000, 10_000_000, 15_000_000, 25_000_000],
}
# Information bars calibration
# Critical: Use slow adaptation (α=0.001) to prevent threshold spiral
# with persistent order flow imbalance (NVDA ~52-60% buys)

# Volume Imbalance Bars (VIBs): θ = Σ b_t × v_t
# Threshold: E[T] × |2v⁺ - E[v]|
VIB_EXPECTED_T = [5_000, 10_000, 20_000, 50_000]

# Tick Imbalance Bars (TIBs): θ = Σ b_t
# Threshold: E[T] × |2P[b=1] - 1|
# TIBs produce ~800x more bars than VIBs at same E[T]
TIB_EXPECTED_T = [500, 1_000, 2_000, 3_000]

# Shared parameters - SLOW adaptation to prevent threshold spiral
IMBALANCE_ALPHA = [0.001]  # Not 0.1! Prevents feedback loop
IMBALANCE_WARMUP = 100  # Longer warmup for stable initialization
```

```python
if trades is not None and len(trades) > 0:
    print("=== Running Calibration Experiment ===\n")

    # Get unique dates
    dates = trades["date"].unique().sort().to_list()

    # Storage for results (accumulates across cells)
    calibration_results = []
    stats_results = []
```

```python
if trades is not None and len(trades) > 0:
    for bar_type, thresholds in CALIBRATION_GRID.items():
        print(f"\n{bar_type.upper()} bars:")

        for threshold in thresholds:
            for date in dates:
                day_trades = trades.filter(pl.col("date") == date).drop("date")
                bars = build_bars_for_day(day_trades, bar_type, threshold)

                if bars is not None:
                    bar_stats = compute_bar_statistics(bars)
                    calibration_results.append(
                        {
                            "bar_type": bar_type,
                            "threshold": str(threshold),
                            "threshold_num": float(threshold),
                            "date": date,
                            "n_bars": len(bars),
                            "expected_t": None,
                            "alpha": None,
                        }
                    )
                    if bar_stats:
                        bar_stats["bar_type"] = bar_type
                        bar_stats["threshold"] = str(threshold)
                        bar_stats["date"] = date
                        stats_results.append(bar_stats)

            # Summary for this threshold
            threshold_bars = [
                r["n_bars"]
                for r in calibration_results
                if r["bar_type"] == bar_type and r["threshold"] == str(threshold)
            ]
            if threshold_bars:
                print(
                    f"  {threshold:>12}: {np.mean(threshold_bars):>6.0f} bars/day "
                    f"(±{np.std(threshold_bars):.0f})"
                )
```

### Imbalance Bar Single-Day Helper
Process one day of trades for a given imbalance bar configuration, appending results.

```python
def _process_imbalance_day(
    day_trades, date, sampler_cls, expected_t, alpha, bar_type_name, tkey, cal_results, stat_results
):
    """Sample imbalance bars for one day and record calibration + stat results."""
    known = day_trades.filter(pl.col("side") != 0)
    if len(known) < 1000:
        return
    try:
        sampler = sampler_cls(
            expected_ticks_per_bar=expected_t,
            alpha=alpha,
            min_bars_warmup=IMBALANCE_WARMUP,
        )
        bars = sampler.sample(known)
    except Exception:
        bars = None
    if bars is not None and len(bars) > 10:
        bar_stats = compute_bar_statistics(bars)
        cal_results.append(
            {
                "bar_type": bar_type_name,
                "threshold": tkey,
                "threshold_num": float(expected_t),
                "date": date,
                "n_bars": len(bars),
                "expected_t": expected_t,
                "alpha": alpha,
            }
        )
        if bar_stats:
            bar_stats.update(
                {
                    "bar_type": bar_type_name,
                    "threshold": tkey,
                    "date": date,
                    "expected_t": expected_t,
                    "alpha": alpha,
                }
            )
            stat_results.append(bar_stats)
```

### Imbalance Bar Calibration Sweep
Run calibration sweep for a single imbalance bar type across thresholds and alphas.

```python
def _calibrate_imbalance_bars(
    trades_df,
    dates,
    expected_t_list,
    alpha_list,
    sampler_cls,
    bar_type_name,
    cal_results,
    stat_results,
):
    """Run calibration sweep for one imbalance bar type."""
    for expected_t in expected_t_list:
        for alpha in alpha_list:
            tkey = f"E[T]={expected_t},α={alpha}"
            for date in dates:
                day = trades_df.filter(pl.col("date") == date).drop("date")
                _process_imbalance_day(
                    day,
                    date,
                    sampler_cls,
                    expected_t,
                    alpha,
                    bar_type_name,
                    tkey,
                    cal_results,
                    stat_results,
                )
            bars_et = [
                r["n_bars"]
                for r in cal_results
                if r["bar_type"] == bar_type_name and r.get("expected_t") == expected_t
            ]
            if bars_et:
                print(
                    f"  E[T]={expected_t:>5}: {np.mean(bars_et):>6.0f} bars/day "
                    f"(±{np.std(bars_et):.0f})"
                )
```

```python
if trades is not None and len(trades) > 0:
    print("\nTICK IMBALANCE BARS (TIBs):")
    print("  Formula: θ = Σ b_t (accumulate signed ticks)")
    _calibrate_imbalance_bars(
        trades,
        dates,
        TIB_EXPECTED_T,
        IMBALANCE_ALPHA,
        TickImbalanceBarSampler,
        "tick_imbalance",
        calibration_results,
        stats_results,
    )
```

```python
if trades is not None and len(trades) > 0:
    print("\nVOLUME IMBALANCE BARS (VIBs):")
    print("  Formula: θ = Σ b_t × v_t (accumulate signed volume)")
    _calibrate_imbalance_bars(
        trades,
        dates,
        VIB_EXPECTED_T,
        IMBALANCE_ALPHA,
        ImbalanceBarSampler,
        "volume_imbalance",
        calibration_results,
        stats_results,
    )
```

```python
if trades is not None and len(trades) > 0:
    calibration_df = pl.DataFrame(
        calibration_results,
        schema={
            "bar_type": pl.Utf8,
            "threshold": pl.Utf8,
            "threshold_num": pl.Float64,
            "date": pl.Utf8,
            "n_bars": pl.Int64,
            "expected_t": pl.Int64,
            "alpha": pl.Float64,
        },
    )
    stats_df = pl.DataFrame(stats_results)

    print(f"\nTotal calibration runs: {len(calibration_df):,}")
    print(f"Statistical analysis runs: {len(stats_df):,}")
```

## 7. Visualizations

Create figures showing calibration results and statistical comparisons.

### Figure 1: Daily Volume Profile

```python
if trades is not None and "daily_stats" in dir() and len(daily_stats) > 0:
    fig, axes = plt.subplots(1, 3, figsize=(14, 4))

    dates_plot = range(len(daily_stats))
    date_labels = [d[:5] for d in daily_stats["date"].to_list()]  # MM-DD

    # Trade count
    ax = axes[0]
    ax.bar(dates_plot, daily_stats["trade_count"].to_numpy() / 1000, color="steelblue")
    ax.axhline(daily_stats["trade_count"].mean() / 1000, color="red", linestyle="--", label="Mean")
    ax.set_xticks(dates_plot)
    ax.set_xticklabels(date_labels, rotation=45, ha="right")
    ax.set_ylabel("Trades (thousands)")
    ax.set_title("Daily Trade Count")
    ax.legend()

    # Volume
    ax = axes[1]
    ax.bar(dates_plot, daily_stats["volume_M"].to_numpy(), color="forestgreen")
    ax.axhline(daily_stats["volume_M"].mean(), color="red", linestyle="--", label="Mean")
    ax.set_xticks(dates_plot)
    ax.set_xticklabels(date_labels, rotation=45, ha="right")
    ax.set_ylabel("Volume (millions)")
    ax.set_title("Daily Volume")
    ax.legend()

    # Dollar volume
    ax = axes[2]
    ax.bar(dates_plot, daily_stats["dollar_volume_M"].to_numpy() / 1000, color="coral")
    ax.axhline(
        daily_stats["dollar_volume_M"].mean() / 1000,
        color="red",
        linestyle="--",
        label="Mean",
    )
    ax.set_xticks(dates_plot)
    ax.set_xticklabels(date_labels, rotation=45, ha="right")
    ax.set_ylabel("Dollar Volume (billions)")
    ax.set_title("Daily Dollar Volume")
    ax.legend()

    plt.suptitle(f"{SYMBOL} Daily Trading Profile ({N_DAYS} Days)", y=1.02)
    show_with_alt(
        fig,
        "Three panels for one symbol over the sample of trading days: the number of trades per day, the shares traded per day, and the dollar value traded per day, each as a series across the days.",
    )

    print(
        "\nThe dispersion above is why a threshold calibrated on one day does not "
        "transfer: the same threshold meets a materially different amount of trading "
        "on a quiet day and a busy one."
    )
```

### Figure 2: Bar Count by Threshold

### Bar Calibration Subplot Helper
Plot bar count vs threshold for one bar type with error bars and target line.

```python
def _plot_bar_calibration(ax, calibration_df, bar_type):
    """Plot bar count vs threshold for one bar type."""
    type_data = calibration_df.filter(
        (pl.col("bar_type") == bar_type) & (pl.col("threshold").str.contains("=").not_())
    )
    if len(type_data) == 0:
        ax.set_title(f"{bar_type.title()} Bars (no data)")
        return

    threshold_stats = (
        type_data.group_by(["threshold", "threshold_num"])
        .agg(
            [
                pl.col("n_bars").mean().alias("mean_bars"),
                pl.col("n_bars").std().alias("std_bars"),
            ]
        )
        .sort("threshold_num")
    )

    thresholds = threshold_stats["threshold_num"].to_numpy()
    mean_bars = threshold_stats["mean_bars"].to_numpy()
    std_bars = threshold_stats["std_bars"].fill_null(0).to_numpy()

    ax.errorbar(
        range(len(thresholds)),
        mean_bars,
        yerr=std_bars,
        marker="o",
        capsize=5,
        linewidth=2,
        markersize=8,
    )
    ax.axhline(
        TARGET_BARS_PER_DAY, color="red", linestyle="--", label=f"Target={TARGET_BARS_PER_DAY}"
    )

    labels = [THRESHOLD_LABEL_FMTS.get(bar_type, str)(t) for t in thresholds]
    ax.set_xticks(range(len(thresholds)))
    ax.set_xticklabels(labels)
    ax.set_xlabel("Threshold")
    ax.set_ylabel("Bars per Day")
    ax.set_title(f"{bar_type.title()} Bars")
    ax.legend()
```

```python
if "calibration_df" in dir() and len(calibration_df) > 0:
    fig, axes = plt.subplots(2, 2, figsize=(14, 10))

    for i, bar_type in enumerate(["tick", "volume", "dollar", "time"]):
        _plot_bar_calibration(axes[i // 2, i % 2], calibration_df, bar_type)

    plt.suptitle(f"Bar Count Calibration ({SYMBOL}, {N_DAYS} Days)", y=1.02)
    show_with_alt(
        fig,
        "One panel per bar type, each plotting the number of bars produced per day against the candidate threshold values swept for that sampler, so the threshold giving a target bar count can be read off.",
    )
```

### Figure 3: Statistical Properties Comparison

```python
if "stats_df" in dir() and len(stats_df) > 0:
    # Select best threshold for each bar type (closest to 500 bars)
    best_thresholds = {}

    for bar_type in ["time", "tick", "volume", "dollar"]:
        type_cal = calibration_df.filter(
            (pl.col("bar_type") == bar_type) & (pl.col("threshold").str.contains("=").not_())
        )
        if len(type_cal) > 0:
            thresh_means = type_cal.group_by("threshold").agg(
                pl.col("n_bars").mean().alias("mean_bars")
            )
            # Find threshold closest to target
            thresh_means = thresh_means.with_columns(
                (pl.col("mean_bars") - TARGET_BARS_PER_DAY).abs().alias("dist")
            )
            best = thresh_means.sort("dist").head(1)["threshold"].item()
            best_thresholds[bar_type] = str(best)
```

```python
if "stats_df" in dir() and len(stats_df) > 0:
    # Add TIBs - find best from calibration results
    tib_cal = calibration_df.filter(pl.col("bar_type") == "tick_imbalance")
    if len(tib_cal) > 0:
        tib_means = tib_cal.group_by("threshold").agg(pl.col("n_bars").mean().alias("mean_bars"))
        tib_means = tib_means.with_columns(
            (pl.col("mean_bars") - TARGET_BARS_PER_DAY).abs().alias("dist")
        )
        best_tib = tib_means.sort("dist").head(1)["threshold"].item()
        best_thresholds["tick_imbalance"] = best_tib
    else:
        best_thresholds["tick_imbalance"] = "E[T]=1000,α=0.001"

    # Add VIBs - find best from calibration results
    vib_cal = calibration_df.filter(pl.col("bar_type") == "volume_imbalance")
    if len(vib_cal) > 0:
        vib_means = vib_cal.group_by("threshold").agg(pl.col("n_bars").mean().alias("mean_bars"))
        vib_means = vib_means.with_columns(
            (pl.col("mean_bars") - TARGET_BARS_PER_DAY).abs().alias("dist")
        )
        best_vib = vib_means.sort("dist").head(1)["threshold"].item()
        best_thresholds["volume_imbalance"] = best_vib
    else:
        best_thresholds["volume_imbalance"] = "E[T]=10000,α=0.001"

    print("Selected thresholds (closest to 500 bars/day):")
    for bt, th in best_thresholds.items():
        print(f"  {bt}: {th}")
```

```python
combined_stats = None
if "stats_df" in dir() and len(stats_df) > 0 and "best_thresholds" in dir():
    best_stats = []
    for bar_type, threshold in best_thresholds.items():
        type_stats = stats_df.filter(
            (pl.col("bar_type") == bar_type) & (pl.col("threshold") == threshold)
        )
        best_stats.append(type_stats)

    if best_stats:
        combined_stats = pl.concat(best_stats)
```

```python
stat_metrics = [
    ("jb_stat", "Jarque-Bera (log scale)", True),
    ("autocorr", "Lag-1 Autocorrelation", False),
    ("kurtosis", "Excess Kurtosis", False),
    ("var_ratio", "Variance Ratio VR(5)", False),
]
bar_types_order = [
    "time",
    "tick",
    "volume",
    "dollar",
    "tick_imbalance",
    "volume_imbalance",
]
box_colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"]
label_map = {"tick_imbalance": "TIB", "volume_imbalance": "VIB"}
```

```python
if combined_stats is not None and len(combined_stats) > 0:
    fig, axes = plt.subplots(2, 2, figsize=(14, 10))

    for i, (metric, title, use_log) in enumerate(stat_metrics):
        ax = axes[i // 2, i % 2]
        data_for_plot, labels = [], []
        for bt in bar_types_order:
            bt_data = combined_stats.filter(pl.col("bar_type") == bt)[metric].to_numpy()
            if len(bt_data) > 0:
                data_for_plot.append(bt_data)
                labels.append(label_map.get(bt, bt.title()))
        if data_for_plot:
            bp = ax.boxplot(data_for_plot, tick_labels=labels, patch_artist=True)
            for patch, color in zip(bp["boxes"], box_colors[: len(bp["boxes"])], strict=False):
                patch.set_facecolor(color)
                patch.set_alpha(0.7)
            if use_log:
                ax.set_yscale("log")
            if metric == "var_ratio":
                ax.axhline(1.0, color="red", linestyle="--", label="Random Walk")
                ax.legend()
            ax.set_ylabel(title)
            ax.set_title(title)

    plt.suptitle(f"Statistical Properties by Bar Type ({SYMBOL}, {N_DAYS} Days)", y=1.02)
    show_with_alt(
        fig,
        "A grid of panels, one statistical diagnostic each, comparing the bar types produced by the different samplers over the sample of days.",
    )
```

### Figure 4: Tick Imbalance Bar Sensitivity (E[T] at fixed α)

Tick imbalance bars (TIBs) are the type that hits the ~500 bars/day target —
volume imbalance bars produce far fewer bars at the same E[T]. With α pinned at
the stable value (larger α feeds the threshold-spiral), the sweep below isolates
how the bar count and its day-to-day stability respond to the E[T] threshold.

```python
imb_has_data = False
if "calibration_df" in dir() and len(calibration_df) > 0:
    # Extract tick-imbalance results (the calibration sweep stores this label;
    # there is no bare "imbalance" bar_type).
    imb_results = calibration_df.filter(pl.col("bar_type") == "tick_imbalance")

    if len(imb_results) > 0 and "expected_t" in imb_results.columns:
        imb_has_data = True
        # Pivot to create heatmap data
        heatmap_data = (
            imb_results.group_by(["expected_t", "alpha"])
            .agg(
                [
                    pl.col("n_bars").mean().alias("mean_bars"),
                    pl.col("n_bars").std().alias("std_bars"),
                ]
            )
            .sort(["expected_t", "alpha"])
        )

        # Create pivot table
        pivot = heatmap_data.pivot(index="expected_t", on="alpha", values="mean_bars").sort(
            "expected_t"
        )

        # Extract data for heatmap
        expected_t_vals = pivot["expected_t"].to_list()
        alpha_vals = [col for col in pivot.columns if col != "expected_t"]
        heatmap_matrix = pivot.select(alpha_vals).to_numpy()
```

```python
if imb_has_data:
    # Created here rather than at the end of the cell above: a figure made in one cell
    # and drawn into in the next is published empty by the inline backend.
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    ax = axes[0]
    im = ax.imshow(heatmap_matrix, cmap="YlOrRd", aspect="auto")
    ax.set_xticks(range(len(alpha_vals)))
    ax.set_xticklabels([f"α={a}" for a in alpha_vals])
    ax.set_yticks(range(len(expected_t_vals)))
    ax.set_yticklabels([f"E[T]={t}" for t in expected_t_vals])
    ax.set_xlabel("Alpha (EWMA decay)")
    ax.set_ylabel("Expected Ticks per Bar")
    ax.set_title("Mean Bars per Day")

    # Add text annotations
    for i in range(len(expected_t_vals)):
        for j in range(len(alpha_vals)):
            val = heatmap_matrix[i, j]
            if not np.isnan(val):
                ax.text(j, i, f"{val:.0f}", ha="center", va="center", fontsize=10)

    fig.colorbar(im, ax=ax, label="Bars/Day")

    # Target highlight - find cell closest to 500
    target_diff = np.abs(heatmap_matrix - TARGET_BARS_PER_DAY)
    best_idx = np.unravel_index(np.nanargmin(target_diff), target_diff.shape)
    ax.add_patch(
        plt.Rectangle(
            (best_idx[1] - 0.5, best_idx[0] - 0.5),
            1,
            1,
            fill=False,
            edgecolor="blue",
            linewidth=3,
        )
    )

    # The CV panel belongs in this cell: a figure drawn across two cells is published
    # half-finished at the end of the first one, with no alt text.
    cv_pivot = (
        heatmap_data.with_columns((pl.col("std_bars") / pl.col("mean_bars")).alias("cv"))
        .pivot(index="expected_t", on="alpha", values="cv")
        .sort("expected_t")
    )

    cv_matrix = cv_pivot.select(alpha_vals).to_numpy()

    ax = axes[1]
    im = ax.imshow(cv_matrix, cmap="YlGn_r", aspect="auto")
    ax.set_xticks(range(len(alpha_vals)))
    ax.set_xticklabels([f"α={a}" for a in alpha_vals])
    ax.set_yticks(range(len(expected_t_vals)))
    ax.set_yticklabels([f"E[T]={t}" for t in expected_t_vals])
    ax.set_xlabel("Alpha (EWMA decay)")
    ax.set_ylabel("Expected Ticks per Bar")
    ax.set_title("Coefficient of variation of the daily bar count")

    for i in range(len(expected_t_vals)):
        for j in range(len(alpha_vals)):
            val = cv_matrix[i, j]
            if not np.isnan(val):
                ax.text(j, i, f"{val:.2f}", ha="center", va="center", fontsize=10)

    fig.colorbar(im, ax=ax, label="CV")

    plt.suptitle(f"Tick Imbalance Bar Sensitivity ({SYMBOL}, α={IMBALANCE_ALPHA[0]})", y=1.02)
    show_with_alt(
        fig,
        "Panels showing how the tick imbalance sampler responds as its target bar size is varied at a fixed decay rate, including the coefficient of variation of the daily bar count.",
    )

    print(f"\nBest parameters for ~{TARGET_BARS_PER_DAY} bars/day:")
    print(f"  E[T]={expected_t_vals[best_idx[0]]}, α={alpha_vals[best_idx[1]]}")
```

## 8. Bar Timing Comparison: How Bars Stretch Along Time

The key insight of information-driven bars is that they **cluster during active periods**
and **spread during quiet periods**. This section visualizes that phenomenon directly.

For a fixed time window (e.g., one hour), we show:



```python
# Build bars for one day to create timing visualization
if trades is not None and len(trades) > 0:
    # Use first trading day
    first_date = trades["date"].unique().sort().head(1).item()
    day_trades = trades.filter(pl.col("date") == first_date).drop("date")

    print(f"Building bars for {first_date} ({len(day_trades):,} trades)")

    # Build bars with calibrated thresholds targeting ~500 bars/day
    bar_data = {}

    # Time bars (1 minute)
    time_bars = (
        day_trades.group_by_dynamic("timestamp", every="1m")
        .agg(
            [
                pl.col("timestamp").first().alias("bar_start"),
                pl.col("timestamp").last().alias("bar_end"),
                pl.col("price").first().alias("open"),
                pl.col("price").max().alias("high"),
                pl.col("price").min().alias("low"),
                pl.col("price").last().alias("close"),
                pl.col("volume").sum().alias("volume"),
                pl.len().alias("tick_count"),
            ]
        )
        .filter(pl.col("volume") > 0)
    )
    time_bars = time_bars.with_columns(
        (pl.col("bar_end") - pl.col("bar_start"))
        .dt.total_nanoseconds()
        .truediv(1e9)
        .alias("duration_sec")
    )
    bar_data["time"] = time_bars
```

```python
if trades is not None and len(trades) > 0:
    # Tick bars (target ~500/day based on tick count)
    avg_daily_ticks = len(day_trades)
    tick_threshold = max(100, avg_daily_ticks // 500)
    tick_sampler = TickBarSampler(ticks_per_bar=tick_threshold)
    tick_bars = tick_sampler.sample(day_trades)
    # Add duration
    if "timestamp" in tick_bars.columns:
        tick_bars = (
            tick_bars.with_columns(pl.col("timestamp").alias("bar_start"))
            .with_columns(pl.col("bar_start").shift(-1).alias("bar_end"))
            .with_columns(
                (pl.col("bar_end") - pl.col("bar_start"))
                .dt.total_nanoseconds()
                .truediv(1e9)
                .alias("duration_sec")
            )
        )
    bar_data["tick"] = tick_bars

    # Volume bars
    total_volume = day_trades["volume"].sum()
    vol_threshold = max(1000, int(total_volume / 500))
    vol_sampler = VolumeBarSampler(volume_per_bar=vol_threshold)
    vol_bars = vol_sampler.sample(day_trades)
    if "timestamp" in vol_bars.columns:
        vol_bars = (
            vol_bars.with_columns(pl.col("timestamp").alias("bar_start"))
            .with_columns(pl.col("bar_start").shift(-1).alias("bar_end"))
            .with_columns(
                (pl.col("bar_end") - pl.col("bar_start"))
                .dt.total_nanoseconds()
                .truediv(1e9)
                .alias("duration_sec")
            )
        )
    bar_data["volume"] = vol_bars
```

```python
if trades is not None and len(trades) > 0:
    total_dollar = (day_trades["price"] * day_trades["volume"]).sum()
    dollar_threshold = max(100_000, int(total_dollar / 500))
    dollar_sampler = DollarBarSampler(dollars_per_bar=dollar_threshold)
    dollar_bars = dollar_sampler.sample(day_trades)
    if "timestamp" in dollar_bars.columns:
        dollar_bars = (
            dollar_bars.with_columns(pl.col("timestamp").alias("bar_start"))
            .with_columns(pl.col("bar_start").shift(-1).alias("bar_end"))
            .with_columns(
                (pl.col("bar_end") - pl.col("bar_start"))
                .dt.total_nanoseconds()
                .truediv(1e9)
                .alias("duration_sec")
            )
        )
    bar_data["dollar"] = dollar_bars
```

```python
if trades is not None and len(trades) > 0:
    # Information bars - use slow adaptation to prevent threshold spiral
    known_side = day_trades.filter(pl.col("side") != 0)
    if len(known_side) > 1000:
        # Tick Imbalance Bars (TIBs): θ = Σ b_t
        tib_sampler = TickImbalanceBarSampler(
            expected_ticks_per_bar=1000, alpha=0.001, min_bars_warmup=100
        )
        tib_bars = tib_sampler.sample(known_side)
        if "timestamp" in tib_bars.columns:
            tib_bars = (
                tib_bars.with_columns(pl.col("timestamp").alias("bar_start"))
                .with_columns(pl.col("bar_start").shift(-1).alias("bar_end"))
                .with_columns(
                    (pl.col("bar_end") - pl.col("bar_start"))
                    .dt.total_nanoseconds()
                    .truediv(1e9)
                    .alias("duration_sec")
                )
            )
        bar_data["tick_imbalance"] = tib_bars

        # Volume Imbalance Bars (VIBs): θ = Σ b_t × v_t
        vib_sampler = ImbalanceBarSampler(
            expected_ticks_per_bar=10000, alpha=0.001, min_bars_warmup=100
        )
        vib_bars = vib_sampler.sample(known_side)
        if "timestamp" in vib_bars.columns:
            vib_bars = (
                vib_bars.with_columns(pl.col("timestamp").alias("bar_start"))
                .with_columns(pl.col("bar_start").shift(-1).alias("bar_end"))
                .with_columns(
                    (pl.col("bar_end") - pl.col("bar_start"))
                    .dt.total_nanoseconds()
                    .truediv(1e9)
                    .alias("duration_sec")
                )
            )
        bar_data["volume_imbalance"] = vib_bars

    print("\nBars created:")
    for name, df in bar_data.items():
        print(f"  {name}: {len(df)} bars")
```

### Figure 5: Bar Timing on Time Axis

This visualization shows how different bar types sample the same time period differently.
Each horizontal line represents one bar type; each marker shows when a bar forms.

```python
y_positions = {
    "time": 6,
    "tick": 5,
    "volume": 4,
    "dollar": 3,
    "tick_imbalance": 2,
    "volume_imbalance": 1,
}
timing_colors = {
    "time": "#1f77b4",
    "tick": "#ff7f0e",
    "volume": "#2ca02c",
    "dollar": "#d62728",
    "tick_imbalance": "#9467bd",
    "volume_imbalance": "#8c564b",
}
```

```python
timestamps = []
if "bar_data" in dir() and bar_data:
    # Find the actual timestamps for filtering
    sample_bars = bar_data.get("time", list(bar_data.values())[0])
    timestamps = sample_bars["timestamp"].to_list() if "timestamp" in sample_bars.columns else []

    if timestamps:
        fig, ax = plt.subplots(figsize=(16, 6))

        for bar_type, bars in bar_data.items():
            if "timestamp" not in bars.columns:
                continue
            # Get timestamps and filter to first 100 bars for clarity
            ts = bars["timestamp"].head(100).to_list()
            y = [y_positions[bar_type]] * len(ts)
            ax.scatter(
                ts,
                y,
                c=timing_colors[bar_type],
                s=30,
                alpha=0.7,
                label=f"{bar_type.title()} ({len(bars)} bars)",
            )
            ax.hlines(
                y_positions[bar_type],
                ts[0],
                ts[-1],
                colors=timing_colors[bar_type],
                alpha=0.3,
                linewidth=1,
            )

        ax.set_yticks(list(y_positions.values()))
        ax.set_yticklabels([k.title() for k in y_positions.keys()])
        ax.set_xlabel("Time")
        ax.set_ylabel("Bar Type")
        ax.set_title("Bar start times over the first hundred bars, by bar type")
        ax.legend(loc="upper right")
        plt.xticks(rotation=45)
        show_with_alt(
            fig,
            "A timeline of when each bar type cut its first hundred bars over the session, one row per bar type, so the clustering of event-driven bars in busy periods can be compared against the even spacing of time bars.",
        )

        print("\nKey observation: Time bars are evenly spaced.")
        print("Event-driven bars cluster during high-activity periods.")
```

## 9. Cross-Perspective Distributions

Each bar type holds something constant while letting other properties vary:
- **Time bars**: Fixed duration → variable volume, ticks
- **Tick bars**: Fixed tick count → variable duration, volume
- **Volume bars**: Fixed volume → variable duration, ticks
- **Dollar bars**: Fixed dollar volume → variable duration, ticks
- **TIBs**: Adaptive tick-imbalance threshold → everything varies
- **VIBs**: Adaptive volume-imbalance threshold → everything varies

This section shows those distributions explicitly.

```python
if "bar_data" in dir() and bar_data:
    fig, axes = plt.subplots(2, 3, figsize=(16, 10))

    bar_types = ["time", "tick", "volume", "dollar", "tick_imbalance", "volume_imbalance"]
    bar_type_colors = {
        "time": "#1f77b4",
        "tick": "#ff7f0e",
        "volume": "#2ca02c",
        "dollar": "#d62728",
        "tick_imbalance": "#9467bd",
        "volume_imbalance": "#8c564b",
    }

    # The horizontal axis is capped at the ninety-ninth percentile across all series:
    # one long tail would otherwise push every other distribution into the first bin.
    ax = axes[0, 0]
    all_durations = []
    for bt in bar_types:
        if bt in bar_data and "duration_sec" in bar_data[bt].columns:
            durations = bar_data[bt]["duration_sec"].drop_nulls().to_numpy()
            durations = durations[durations > 0]
            if len(durations) > 10:
                ax.hist(durations, bins=50, alpha=0.5, label=bt.title(), color=bar_type_colors[bt])
                all_durations.append(durations)
    ax.set_xlabel("Duration (seconds)")
    ax.set_ylabel("Count")
    ax.set_title("Bar Duration Distribution")
    ax.legend()
    if all_durations:
        xmax = float(np.quantile(np.concatenate(all_durations), 0.99))
        ax.set_xlim(0, xmax)

    # Plot 2: Volume per bar
    ax = axes[0, 1]
    all_volumes = []
    for bt in bar_types:
        if bt in bar_data and "volume" in bar_data[bt].columns:
            volumes = bar_data[bt]["volume"].drop_nulls().to_numpy()
            if len(volumes) > 10:
                ax.hist(
                    volumes / 1000, bins=50, alpha=0.5, label=bt.title(), color=bar_type_colors[bt]
                )
                all_volumes.append(volumes / 1000)
    ax.set_xlabel("Volume (thousands)")
    ax.set_ylabel("Count")
    ax.set_title("Bar Volume Distribution")
    ax.legend()
    if all_volumes:
        xmax = float(np.quantile(np.concatenate(all_volumes), 0.99))
        ax.set_xlim(0, xmax)

    # Plot 3: Tick count per bar
    ax = axes[0, 2]
    all_ticks = []
    for bt in bar_types:
        if bt in bar_data and "tick_count" in bar_data[bt].columns:
            ticks = bar_data[bt]["tick_count"].drop_nulls().to_numpy()
            if len(ticks) > 10:
                ax.hist(ticks, bins=50, alpha=0.5, label=bt.title(), color=bar_type_colors[bt])
                all_ticks.append(ticks)
    ax.set_xlabel("Tick Count")
    ax.set_ylabel("Count")
    ax.set_title("Bar Tick Count Distribution")
    ax.legend()
    if all_ticks:
        xmax = float(np.quantile(np.concatenate(all_ticks), 0.99))
        ax.set_xlim(0, xmax)

    # Plot 4: CV comparison (coefficient of variation)
    ax = axes[1, 0]
    cv_data = []
    for bt in bar_types:
        if bt not in bar_data:
            continue
        bars = bar_data[bt]
        row = {"bar_type": bt.title()}
        if "duration_sec" in bars.columns:
            d = bars["duration_sec"].drop_nulls().to_numpy()
            d = d[d > 0]
            row["duration_cv"] = np.std(d) / np.mean(d) if len(d) > 10 else np.nan
        else:
            row["duration_cv"] = np.nan
        if "volume" in bars.columns:
            v = bars["volume"].drop_nulls().to_numpy()
            row["volume_cv"] = np.std(v) / np.mean(v) if len(v) > 10 else np.nan
        else:
            row["volume_cv"] = np.nan
        if "tick_count" in bars.columns:
            t = bars["tick_count"].drop_nulls().to_numpy()
            row["tick_cv"] = np.std(t) / np.mean(t) if len(t) > 10 else np.nan
        else:
            row["tick_cv"] = np.nan
        cv_data.append(row)

    if cv_data:
        x = np.arange(len(cv_data))
        width = 0.25
        for i, col in enumerate(["duration_cv", "volume_cv", "tick_cv"]):
            values = [r.get(col, np.nan) for r in cv_data]
            ax.bar(x + i * width, values, width, label=col.replace("_cv", "").title())
        ax.set_xticks(x + width)
        ax.set_xticklabels([r["bar_type"] for r in cv_data], rotation=30, ha="right")
        ax.set_ylabel("Coefficient of Variation")
        ax.set_title("Variability by Bar Type")
        ax.legend()
        ax.axhline(0, color="black", linewidth=0.5)

    # Plot 5: Box plots of duration
    ax = axes[1, 1]
    duration_data = []
    duration_labels = []
    for bt in bar_types:
        if bt in bar_data and "duration_sec" in bar_data[bt].columns:
            d = bar_data[bt]["duration_sec"].drop_nulls().to_numpy()
            d = d[(d > 0) & (d < np.percentile(d, 99))]
            if len(d) > 10:
                duration_data.append(d)
                duration_labels.append(bt.title())
    if duration_data:
        bp = ax.boxplot(duration_data, tick_labels=duration_labels, patch_artist=True)
        for patch, bt in zip(bp["boxes"], duration_labels, strict=False):
            patch.set_facecolor(bar_type_colors.get(bt.lower(), "gray"))
            patch.set_alpha(0.7)
    ax.set_ylabel("Duration (seconds)")
    ax.set_title("Duration Distribution by Bar Type")
    plt.setp(ax.get_xticklabels(), rotation=30, ha="right")

    # Plot 6: Summary statistics table
    ax = axes[1, 2]
    ax.axis("off")
    summary_text = "=== Bar Characteristics ===\n\n"
    summary_text += f"{'Bar Type':<12} {'Count':>8} {'Avg Duration':>14} {'Avg Volume':>12}\n"
    summary_text += "-" * 50 + "\n"
    for bt in bar_types:
        if bt not in bar_data:
            continue
        bars = bar_data[bt]
        n = len(bars)
        if "duration_sec" in bars.columns:
            d = bars["duration_sec"].drop_nulls().to_numpy()
            avg_dur = f"{np.mean(d[d > 0]):.1f}s" if len(d) > 0 else "N/A"
        else:
            avg_dur = "N/A"
        if "volume" in bars.columns:
            v = bars["volume"].drop_nulls().to_numpy()
            avg_vol = f"{np.mean(v) / 1000:.1f}K" if len(v) > 0 else "N/A"
        else:
            avg_vol = "N/A"
        summary_text += f"{bt.title():<12} {n:>8} {avg_dur:>14} {avg_vol:>12}\n"
    ax.text(
        0.1,
        0.9,
        summary_text,
        transform=ax.transAxes,
        fontfamily="monospace",
        fontsize=10,
        verticalalignment="top",
    )

    plt.suptitle(f"Cross-Perspective Bar Distributions ({first_date})", y=1.02, fontsize=14)
    show_with_alt(
        fig,
        "Six panels in two rows of three, comparing what each sampler produced on one session. The top row holds three overlaid histograms, one colour per bar type: bar duration in seconds, bar volume in thousands of shares, and bar tick count. Each of those three horizontal axes is capped at the ninety-ninth percentile across the series drawn in it, so one long tail cannot push the others into the first bin. Bottom left is a grouped bar chart of the coefficient of variation of duration, volume and tick count for each bar type. Bottom centre is a box plot of bar duration by bar type. Bottom right is not a chart: it is a table drawn as text inside the figure, listing each bar type with its count, mean duration and mean volume, and the same numbers are printed below the figure.",
    )

    # The table in the sixth panel is drawn as pixels, so a screen reader cannot read
    # it. Print the same text.
    print(summary_text)

    print("\nKey insight:")
    print("- Time bars: Low duration CV (fixed), high volume CV (varies)")
    print("- Volume bars: Low volume CV (fixed), high duration CV (varies)")
    print("- Information bars: All dimensions vary based on market activity")
```

## 10. Summary Statistics Table (Calibrated Thresholds)

```python
if "stats_df" in dir() and len(stats_df) > 0 and "best_thresholds" in dir():
    # Compute summary for best thresholds
    summary_rows = []

    for bar_type, threshold in best_thresholds.items():
        type_stats = stats_df.filter(
            (pl.col("bar_type") == bar_type) & (pl.col("threshold") == threshold)
        )

        if len(type_stats) > 0:
            summary_rows.append(
                {
                    "bar_type": bar_type.title(),
                    "threshold": str(threshold),
                    "n_bars_mean": type_stats["n_bars"].mean(),
                    "n_bars_std": type_stats["n_bars"].std(),
                    "jb_median": type_stats["jb_stat"].median(),
                    "autocorr_median": type_stats["autocorr"].median(),
                    "kurtosis_median": type_stats["kurtosis"].median(),
                    "var_ratio_median": type_stats["var_ratio"].median(),
                }
            )

    summary_df = pl.DataFrame(summary_rows)

    print("\n=== Summary Statistics (Best Thresholds) ===\n")
    print(summary_df)

    # Interpretation
    print("\n--- Interpretation ---")
    print("| Metric | Meaning | Better Value |")
    print("|--------|---------|--------------|")
    print("| JB Stat | Distance from normality | Lower |")
    print("| Autocorr | Serial dependence | Closer to 0 |")
    print("| Kurtosis | Tail weight (0=normal) | Lower |")
    print("| VR(5) | Random walk test | Closer to 1 |")
```

## 11. Save Calibration Results

```python
if "calibration_df" in dir() and len(calibration_df) > 0:
    # Save calibration results
    calibration_df.write_parquet(OUTPUT_DIR / f"{SYMBOL}_calibration_results.parquet")
    print(f"Saved: {SYMBOL}_calibration_results.parquet")

if "stats_df" in dir() and len(stats_df) > 0:
    stats_df.write_parquet(OUTPUT_DIR / f"{SYMBOL}_bar_statistics.parquet")
    print(f"Saved: {SYMBOL}_bar_statistics.parquet")

# Save daily profile
if "daily_stats" in dir() and len(daily_stats) > 0:
    daily_stats.write_parquet(OUTPUT_DIR / f"{SYMBOL}_daily_profile.parquet")
    print(f"Saved: {SYMBOL}_daily_profile.parquet")

# Save bar data for downstream use (e.g., Ch5 GT-GAN)
if "bar_data" in dir() and bar_data:
    for bar_type_name, bars_df in bar_data.items():
        if bars_df is not None and len(bars_df) > 0:
            bar_file = OUTPUT_DIR / f"{SYMBOL}_{bar_type_name}_bars.parquet"
            bars_df.write_parquet(bar_file)
            print(f"Saved: {bar_file.name} ({len(bars_df)} bars)")
```

## 12. Calibration Recommendations

Based on this multi-day analysis, here are production-ready recommendations:

### Recommended Thresholds for NVDA (~500 bars/day)

The calibration tables above give each sampler's mean daily bar count and the standard
deviation of that count across days. Read those two against each other: a sampler
whose day-to-day standard deviation approaches its mean is not delivering a
predictable number of bars, and downstream code that assumes one will break on the
quiet days. The comparison table that follows reports distributional diagnostics
instead, which is a separate question from stability.

### How to calibrate a threshold

**Calibrate off the median day, not the mean.** A single heavy day pulls the mean up
and leaves a threshold that produces too few bars on every ordinary day.

**Expect the bar count to move, and decide how much movement is acceptable before
looking.** Fixed-threshold bars track activity by construction, so their count varies
with the market; imbalance bars vary more, because their threshold adapts as well.

**Dollar bars are the reasonable default.** They weight by value rather than share
count, which is the unit portfolio arithmetic is done in, and they absorb a price move
that would change what a volume threshold means. A volume threshold calibrated on a
$50 stock samples very differently after it doubles.

**Imbalance bars buy information-driven sampling at the cost of predictability.** Use a
slow decay so the threshold does not run away, and a larger target so that each bar
represents a meaningful accumulation of one-sided flow rather than noise.

### Key Findings

1. **Event-driven bars produce lower Jarque-Bera and lower lag-1
   autocorrelation than time bars** on the configurations tested above
   (see the comparison table for the per-bar-type numbers). This notebook
   does not evaluate downstream ML performance on the resulting series.

2. **A threshold calibrated on one day is calibrated on that day.** Daily volume
   varies enough across this sample that a threshold fitted to a single session
   produces a materially different bar count on another - the dispersion measured
   above is what to size that risk against.

3. **Variance Ratio VR(5) ≈ 1** for all bar types:
   - Returns are not distinguishable from a random walk by VR(5)
   - No predictability detected at the 5-bar horizon in this test

---

## Bar Type Summary Comparison (Single Day)

For direct comparison in Chapter 3 (Table 3.7), we build bars for one
representative day using recommended thresholds and report statistical
properties. This uses DataBento's direct aggressor labels (no estimation).

```python
# Build all bar types for first trading day with recommended thresholds
if trades is not None and len(trades) > 0:
    summary_date = trades["date"].unique().sort().head(1).item()
    summary_day = trades.filter(pl.col("date") == summary_date).drop("date")
    n_summary = len(summary_day)
    buy_frac_summary = (summary_day["side"] > 0).mean()

    # Every trade here carries a side already: the unclassified ones were reported and
    # dropped at load, so a "usable" share computed now could only ever be 100%.
    print(f"Day: {summary_date}, Trades: {n_summary:,}, P[buy] = {buy_frac_summary:.3f}")

    # Build bars with thresholds targeting ~400-500 bars/day
    summary_configs = [
        ("Time (1-min)", None, None),
        ("Tick (500)", "tick", 500),
        ("Volume (50K)", "volume", 50_000),
        ("Dollar ($5M)", "dollar", 5_000_000),
    ]

    summary_results = []
    for label, bt, thresh in summary_configs:
        if bt is None:
            # Time bars
            bars = (
                summary_day.group_by_dynamic("timestamp", every="1m")
                .agg(
                    [
                        pl.col("price").first().alias("open"),
                        pl.col("price").max().alias("high"),
                        pl.col("price").min()

Shown in full with attribution under the source's licence. Licence: MIT

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.