Custom and multi-column bars#

The built-in string aggregations ("ohlcv", "mean", etc.) cover the most common bar statistics. When you need something different, any screamer functor can serve as the reducer: Resample calls it with reset() at each bar boundary and treats its final output as the bar's value.

For multi-column bars, run one Resample per statistic over the same clock and bar width, then align the results with CombineLatest. Wrapping the whole structure in a Pipeline lets you define the bar schema once and bind new data at call time.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
from screamer import (
    Resample, CombineLatest, Input, Pipeline,
    ExpandingSkew, ExpandingSlope, ExpandingStd, ExpandingSum,
    First, Last, ExpandingMax, ExpandingMin,
    PosPart, NegPart,
)

rng = np.random.default_rng(42)
n = 80

# Integer clock: each tick lands 1-2 units after the last.
timestamps = np.cumsum(rng.integers(1, 3, size=n)).astype(np.int64)

# Mid-price: a slow random walk around 100.
price = 100.0 + np.cumsum(rng.standard_normal(n) * 0.3)

# Signed volume: positive = buyer-initiated, negative = seller-initiated.
volume = rng.exponential(scale=50.0, size=n)
signed_volume = volume * rng.choice([-1.0, 1.0], size=n)

# Bar width in index units.
BAR = 20

A single functor as the bar reducer#

Pass any screamer functor as agg=. The functor is reset() at each bar boundary, fed every tick in the bar in order, and its final output becomes that bar's value. The statistic is therefore computed causally from the ticks in the bar and nothing else.

ExpandingSkew() accumulates the first three central moments and returns the bias-corrected skewness coefficient at the bar close. Bars with fewer than three ticks yield NaN.

skew_bars = Resample(freq=BAR, agg=ExpandingSkew())(price, timestamps)

values, index = skew_bars
print("bar labels:    ", index)
print("intra-bar skew:", values.round(4))
bar labels:     [  0  20  40  60  80 100 120]
intra-bar skew: [ 0.449   0.1656 -0.0889  0.027   0.419   0.2307     nan]

Several statistics via CombineLatest composition#

Run one Resample per statistic over the same clock and bar width. Because all share the same freq=, they close on the same grid. CombineLatest merges the individual (values, index) tuples into a single multi-column result.

ExpandingSlope() fits a least-squares line to the tick positions within the bar and returns the slope at close. A positive slope means price trended up inside the bar.

slope_bars  = Resample(freq=BAR, agg=ExpandingSlope())(price, timestamps)

# CombineLatest aligns two (values, index) tuples onto a shared grid.
combined = CombineLatest()(skew_bars, slope_bars)
combined_vals, combined_idx = combined

pd.DataFrame(
    combined_vals,
    index=pd.Index(combined_idx, name="bar_open"),
    columns=["skew", "slope"],
).round(4)
skew slope
bar_open
0 0.4490 0.0900
20 0.1656 -0.0389
40 -0.0889 -0.0779
60 0.0270 -0.0588
80 0.4190 -0.1917
100 0.2307 -0.0882
120 NaN NaN
close = Resample(freq=BAR, agg="last")(price, timestamps)

fig, axes = plt.subplots(3, 1, figsize=(9, 6), sharex=True)

axes[0].plot(close[1], close[0], marker="o", ms=5, color="steelblue")
axes[0].set_ylabel("close")
axes[0].set_title("Per-bar close, intra-bar skew, and intra-bar slope")

axes[1].plot(combined_idx, combined_vals[:, 0], marker="o", ms=5, color="darkorange")
axes[1].axhline(0, color="0.6", lw=0.6)
axes[1].set_ylabel("skew")

axes[2].plot(combined_idx, combined_vals[:, 1], marker="o", ms=5, color="seagreen")
axes[2].axhline(0, color="0.6", lw=0.6)
axes[2].set_ylabel("slope")
axes[2].set_xlabel("bar open index")

plt.tight_layout()
../_images/b6f8b8d3e862ece73f87002391740e3150058e70b1a4129683c0f568e5c894a8.png

Multi-column OHLC + buy/sell bars in a Pipeline#

A Pipeline lets you define the bar structure once and bind data at call time. Input nodes are named placeholders; applying a functor to one builds an expression, not a computation.

PosPart and NegPart decompose signed volume: every real number satisfies x = PosPart(x) - NegPart(x), with both parts non-negative. Summing each part over a bar splits total traded volume into buyer- and seller-initiated halves.

Every Resample shares the same freq= and clock, so all columns close on the same bar boundaries and cannot drift relative to each other.

rng2 = np.random.default_rng(7)
n2   = 400
BAR2 = 40

t_arr     = np.arange(n2, dtype=np.int64)
price_arr = 100.0 + np.cumsum(rng2.normal(size=n2))
vol_arr   = rng2.normal(size=n2) * rng2.integers(1, 5, size=n2)

# Declare Input placeholders (no data bound yet).
price_in, vol_in = Input("price"), Input("vol")

# Build one node per column.
open_b  = Resample(freq=BAR2, agg=First())(price_in)
high_b  = Resample(freq=BAR2, agg=ExpandingMax())(price_in)
low_b   = Resample(freq=BAR2, agg=ExpandingMin())(price_in)
close_b = Resample(freq=BAR2, agg=Last())(price_in)
buy_b   = Resample(freq=BAR2, agg=ExpandingSum())(PosPart()(vol_in))
sell_b  = Resample(freq=BAR2, agg=ExpandingSum())(NegPart()(vol_in))

# Align all six columns onto one shared bar grid.
bars_node = CombineLatest()(open_b, high_b, low_b, close_b, buy_b, sell_b)
COLUMNS = ["open", "high", "low", "close", "buy", "sell"]

pipe = Pipeline([price_in, vol_in], [bars_node])
# Bind data at call time.
ohlcv, ohlcv_idx = pipe(
    price=(price_arr, t_arr),
    vol=(vol_arr, t_arr),
)

pd.DataFrame(
    ohlcv.round(2),
    index=pd.Index(ohlcv_idx.astype(int), name="bar_open"),
    columns=COLUMNS,
)
open high low close buy sell
bar_open
0 100.00 100.30 83.78 84.19 30.14 41.24
40 84.30 88.11 83.03 85.89 23.37 51.61
80 85.46 87.37 81.26 82.43 34.53 49.69
120 82.52 82.52 71.97 72.95 9.15 34.56
160 71.90 73.64 67.07 73.61 39.73 39.35
200 72.36 77.95 63.67 64.33 43.55 43.31
240 63.87 65.47 56.08 57.55 44.23 31.67
280 57.64 63.41 56.11 60.23 47.11 31.38
320 59.46 64.86 58.78 62.94 37.49 46.11
360 63.45 65.97 57.71 57.71 24.12 61.95
o, h, l, c  = ohlcv[:, 0], ohlcv[:, 1], ohlcv[:, 2], ohlcv[:, 3]
buy, sell   = ohlcv[:, 4], ohlcv[:, 5]

fig = plt.figure(figsize=(10, 6))
gs = GridSpec(2, 1, height_ratios=[3, 1], hspace=0.05)
ax1 = fig.add_subplot(gs[0])
ax2 = fig.add_subplot(gs[1], sharex=ax1)

x = np.arange(len(o))
colors = np.where(c >= o, "seagreen", "crimson")
ax1.vlines(x, l, h, color=colors, lw=1)
ax1.bar(x, np.abs(c - o), bottom=np.minimum(o, c), color=colors, width=0.6)
ax1.set_ylabel("price")
ax1.set_title("OHLC bars with buy and sell volume")
ax1.tick_params(labelbottom=False)

ax2.bar(x,  buy,  color="seagreen", width=0.6)
ax2.bar(x, -sell, color="crimson",  width=0.6)
ax2.axhline(0, color="k", lw=0.5)
ax2.set_ylabel("volume")
ax2.set_xlabel("bar")
Text(0.5, 0, 'bar')
../_images/e24416133023b263670a6d28f8137adcfa0fa01316a22c0138234d0df7e6a9e5.png

Extending the bar#

Adding a column means adding one more Resample node and including it in CombineLatest. The rest of the graph is unchanged.

The example below adds realized volatility (rvol): the standard deviation of price ticks within the bar, computed by ExpandingStd(). Note that this is a price dispersion measure, not a traded volume column.

price3, vol3 = Input("price"), Input("vol")
close3 = Resample(freq=BAR2, agg=Last())(price3)
rvol3  = Resample(freq=BAR2, agg=ExpandingStd())(price3)   # price std per bar
flow3  = Resample(freq=BAR2, agg=ExpandingSum())(vol3)

bars3 = CombineLatest()(close3, rvol3, flow3)
COLS3 = ["close", "rvol", "flow"]

out, out_idx = Pipeline([price3, vol3], [bars3])(
    price=(price_arr, t_arr),
    vol=(vol_arr, t_arr),
)

pd.DataFrame(
    out.round(3),
    index=pd.Index(out_idx.astype(int), name="bar_open"),
    columns=COLS3,
)
close rvol flow
bar_open
0 84.193 6.035 -11.095
40 85.887 1.356 -28.248
80 82.429 1.666 -15.154
120 72.953 2.311 -25.410
160 73.608 1.834 0.384
200 64.329 4.412 0.250
240 57.554 2.865 12.557
280 60.235 2.171 15.723
320 62.944 1.552 -8.615
360 57.706 2.038 -37.824