How BanTA's BarEnv Manages Indicator State: Lazy Evaluation and Deterministic Caching

BanTA's BarEnv stores all indicator series in a centralized Items map and uses a lazy-evaluation cache via Series.To and Series.Cached to ensure each indicator value is computed exactly once per bar, eliminating redundant calculations during backtesting or live trading.

The BarEnv struct in the banbox/banta repository serves as the central container for OHLCV data and indicator state management. Understanding how BarEnv coordinates the lifecycle of derived series—from creation to per-bar caching—is essential for building efficient trading strategies that process thousands of bars without memory exhaustion or duplicate computations.

Core Architecture: The BarEnv State Container

Centralized Series Storage

At the heart of BanTA's state management is the BarEnv struct defined in types.go. Unlike libraries that scatter indicator buffers across global variables, BarEnv maintains a unified registry:

// types.go
type BarEnv struct {
    TimeStart  int64
    TimeStop   int64
    Exchange   string
    MarketType string
    Symbol     string
    TimeFrame  string
    TFMSecs    int64
    BarNum     int
    MaxCache   int
    VNum       int               // auto-incremented series ID
    Open, High, Low, Close, Volume, Quote, BuyVolume, TradeNum *Series
    Data       sync.Map
    Items      map[int]*Series   // every series created by the env
    Lock       sync.Mutex
}

File link: [types.go](https://github.com/banbox/banta/blob/main/types.go#L24-L45)

Key design elements:

  • Root series (Open through TradeNum) store the raw OHLCV data received via OnBar.
  • Items map holds every Series instance—both root and derived—indexed by a unique integer ID (VNum).
  • VNum auto-increments via NewSeries, ensuring no collision between indicator instances.

Series Registration and Lifecycle

Every series—whether raw OHLCV or derived indicator—registers itself with the environment through NewSeries in core.go:

// core.go
func (e *BarEnv) NewSeries(data []float64) *Series {
    subs := make(map[string]map[int]*Series)
    xlogs := make(map[int]*CrossLog)
    res := e.newSeries(data, nil, nil, nil, subs, xlogs)
    e.VNum += 1
    if e.Items == nil {
        e.Items = make(map[int]*Series)
    }
    e.Items[res.ID] = res
    return res
}

File link: [core.go](https://github.com/banbox/banta/blob/main/core.go#L100-L110)

This registration guarantees that all indicator state is reachable through BarEnv.Items, enabling operations like deep cloning and serialization.

Lazy Evaluation and the Per-Bar Cache

The cornerstone of BanTA's performance is its lazy-evaluation cache, which prevents redundant indicator calculations when the same indicator is referenced multiple times within a single bar.

The To Method and Cache Hierarchy

The Series.To method implements a two-level cache using the Subs map:

// core.go
func (s *Series) To(k string, v int) *Series {
    sub, _ := s.Subs[k]
    if sub == nil {
        sub = make(map[int]*Series)
        s.Subs[k] = sub
    }
    old, _ := sub[v]
    if old == nil {
        old = s.Env.NewSeries(nil)   // a fresh series for this bar
        sub[v] = old
    }
    return old
}

File link: [core.go](https://github.com/banbox/banta/blob/main/core.go#L78-L90)

Cache mechanics:

  • Key (k): String identifier for the indicator type (e.g., "_sma", "_ema").
  • Variant (v): Integer encoding parameters (period, smoothing constants, etc.).
  • First call: Creates a new empty series via Env.NewSeries, registers it in Subs, and returns it.
  • Subsequent calls: Returns the existing series from the cache.

Deterministic Caching with Cached

The Series.Cached method provides the validation logic that determines whether computation is necessary:

func (s *Series) Cached() bool { return s.Time >= s.Env.TimeStop }

When an indicator function runs, it follows this exact pattern:

  1. Call To to obtain the result series for the specific parameter set.
  2. Check Cached(); if true, return immediately (value already computed for this bar).
  3. Calculate the indicator value.
  4. Call Append to store the value and update the series timestamp.

This deterministic per-bar caching ensures that complex indicator chains compute each node exactly once, regardless of how many times the indicator is referenced in strategy logic.

Bar Ingestion and Memory Management

The OnBar Lifecycle

When new market data arrives, BarEnv.OnBar (and its variant OnBar2) orchestrates the state update:

// core.go
func (e *BarEnv) OnBar2(barMS, endMS int64, open, high, low, close,
    volume, quote, buyVolume float64, tradeNum int64) {
    e.TimeStart = barMS
    e.TimeStop = endMS
    e.BarNum += 1

    if e.Open == nil {               // first bar → create root series
        e.Open = e.NewSeries([]float64{open})
        e.High = e.NewSeries([]float64{high})
        e.Low  = e.NewSeries([]float64{low})
        e.Close = e.NewSeries([]float64{close})
        e.Volume = e.NewSeries([]float64{volume})
        e.Quote = e.NewSeries([]float64{quote})
        e.BuyVolume = e.NewSeries([]float64{buyVolume})
        e.TradeNum = e.NewSeries([]float64{float64(tradeNum)})
        if e.MaxCache == 0 {
            e.MaxCache = 1000
        }
    } else {                         // subsequent bars → append
        e.Open.Data = append(e.Open.Data, open)
        e.High.Data = append(e.High.Data, high)
        e.Low.Data = append(e.Low.Data, low)
        e.Close.Data = append(e.Close.Data, close)
        e.Volume.Data = append(e.Volume.Data, volume)
        e.Quote.Data = append(e.Quote.Data, quote)
        e.BuyVolume.Data = append(e.BuyVolume.Data, buyVolume)
        e.TradeNum.Data = append(e.TradeNum.Data, float64(tradeNum))
        e.TrimOverflow()
    }
}

File link: [core.go](https://github.com/banbox/banta/blob/main/core.go#L28-L70)

Critical transitions:

  • Timestamp invalidation: Updating TimeStop invalidates previous Cached() states, forcing new calculations for the incoming bar.
  • BarNum increment: Provides a deterministic counter for internal logic.
  • TrimOverflow invocation: Maintains memory bounds after each append operation.

Automatic Overflow Trimming

To prevent unbounded memory growth during long-running backtests, BarEnv implements automatic trimming:

// core.go
func (e *BarEnv) TrimOverflow() {
    dataLen := e.Close.Len()
    trimLen := int(float64(e.MaxCache) * 1.5)
    if dataLen < trimLen || trimLen <= 0 {
        return
    }
    e.Open.Cut(e.MaxCache)
    e.High.Cut(e.MaxCache)
    e.Low.Cut(e.MaxCache)
    e.Close.Cut(e.MaxCache)
}

File link: [core.go](https://github.com/banbox/banta/blob/main/core.go#L88-L98)

Memory management strategy:

  • Threshold: Triggers when data exceeds MaxCache * 1.5 (default 1500 bars when MaxCache is 1000).
  • Propagation: Series.Cut recursively trims child series in the Subs map, ensuring that derived indicators like moving averages remain synchronized with their parent series.
  • Preservation: Retains exactly MaxCache most recent values, sufficient for rolling-window calculations.

Practical Indicator Implementation

To demonstrate the state management pattern in practice, examine the SMA (Simple Moving Average) implementation in sta_inds.go:

// sta_inds.go
func SMA(obj *Series, period int) *Series {
    res := obj.To("_sma", period)    // ← cached series for this period
    if res.Cached() { return res }

    // Compute using the already-implemented Sum() helper
    midObj := Sum(obj, period)
    if midObj.Len() >= period {
        res.Append(midObj.Get(0) / float64(period))
    } else {
        res.Append(math.NaN())
    }
    return res
}

File link: [sta_inds.go](https://github.com/banbox/banta/blob/main/sta_inds.go#L87-L101)

Implementation pattern:

  1. Cache retrieval: obj.To("_sma", period) obtains the dedicated result series for this parameter set.
  2. Short-circuit: res.Cached() checks if res.Time >= res.Env.TimeStop, returning immediately if the current bar is already processed.
  3. Computation: Calculates the indicator using helper functions (e.g., Sum).
  4. State update: res.Append stores the value and updates the series timestamp, marking this bar as cached.

This pattern appears consistently across all indicators in BanTA, ensuring that indicator state remains deterministic and computationally efficient regardless of strategy complexity.

Cloning and Resetting Environments

For advanced backtesting scenarios—such as running multiple strategy variants from the same starting point—BarEnv provides state manipulation utilities.

Deep Copying with Clone

The Clone method creates a complete, independent snapshot of the environment:

func (e *BarEnv) Clone() *BarEnv { … }

File link: [core.go](https://github.com/banbox/banta/blob/main/core.go#L38-L86)

This operation duplicates:

  • The Items map containing every series by ID.
  • Root OHLCV data slices.
  • The Subs hierarchy, ensuring that cloned indicators maintain their caching relationships.

Selective Reset with ResetTo

To compare strategy variations without reallocating memory, use ResetTo:

func (e *BarEnv) ResetTo(env *BarEnv) { … }

File link: [core.go](https://github.com/banbox/banta/blob/main/core.go#L88-L119)

This replaces the non-OHLCV series (indicators) in the target environment with those from the source, effectively rolling back or fast-forwarding indicator state while preserving the underlying price data. Both methods rely on Series.CopyTo, which respects the caching semantics to ensure consistency.

Summary

  • Centralized Storage: BarEnv maintains all series in the Items map, with each series assigned a unique ID via VNum, providing a single source of truth for indicator state.
  • Lazy Evaluation: The Series.To method implements a per-bar cache keyed by indicator name and parameters, ensuring calculations occur only once per unique configuration.
  • Deterministic Caching: Series.Cached validates state freshness by comparing the series timestamp against Env.TimeStop, preventing redundant computation during indicator chaining.
  • Memory Management: TrimOverflow and Series.Cut automatically prune historical data beyond MaxCache, recursively trimming derived indicators to maintain synchronization while limiting memory footprint.
  • State Portability: Clone and ResetTo enable deep copying and selective state replacement, supporting complex backtesting workflows without requiring indicator recalculation from scratch.

Frequently Asked Questions

How does BanTA ensure that indicators are not recalculated multiple times for the same bar?

BanTA uses a lazy-evaluation cache mechanism. When an indicator like SMA is called, it invokes Series.To with a unique key combining the indicator name (e.g., "_sma") and parameter hash (e.g., period 14). This returns a dedicated result series. The indicator then checks Series.Cached(), which compares the series' internal timestamp against the current BarEnv.TimeStop. If the series is already current, the function returns immediately; otherwise, it computes the value, appends it to the result series, and updates the timestamp. This guarantees exactly one computation per bar per unique indicator configuration.

What happens to indicator state when the BarEnv reaches its MaxCache limit?

When the number of bars exceeds MaxCache * 1.5, the TrimOverflow method triggers automatically after each new bar is appended. This method calls Series.Cut(MaxCache) on all root OHLCV series, which removes the oldest data points from the underlying float64 slices. Because each Series maintains a Subs map containing derived indicators (child series), the Cut operation propagates recursively to all dependent indicators. This preserves the most recent MaxCache values for all indicators while freeing memory from obsolete historical bars, ensuring that rolling-window calculations remain accurate for the available history.

Can I clone the entire indicator state to test multiple strategies simultaneously?

Yes, the BarEnv.Clone() method performs a deep copy of the entire environment state, including all indicator series. When invoked, it duplicates the Items map (which holds every series by ID), copies the root OHLCV data slices, and recursively clones the Subs relationships that define indicator dependencies. This creates an independent snapshot where you can feed additional bars or apply different logic without affecting the original environment. For scenarios requiring selective state replacement rather than full duplication, ResetTo allows you to swap the non-OHLCV series (indicators) from another environment while preserving the underlying price data, enabling efficient A/B testing of strategy variations.

How does the Series.To method handle different parameter sets for the same indicator?

The Series.To method implements a two-level cache using the Subs field (type map[string]map[int]*Series). The first-level key (k) is a string identifier for the indicator type (e.g., "_sma" for Simple Moving Average, "_ema" for Exponential Moving Average). The second-level key (v) is an integer that encodes the specific parameters (period, smoothing factors, etc.). When an indicator is requested, To checks if a series already exists for that specific combination. If not, it creates a new empty series via Env.NewSeries, registers it in the Subs map, and returns it. This design ensures that SMA(close, 14) and SMA(close, 20) maintain separate state series while sharing the same source data, preventing parameter collision and ensuring deterministic results for each configuration.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →