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 (
OpenthroughTradeNum) store the raw OHLCV data received viaOnBar. Itemsmap holds everySeriesinstance—both root and derived—indexed by a unique integer ID (VNum).VNumauto-increments viaNewSeries, 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 inSubs, 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:
- Call
Toto obtain the result series for the specific parameter set. - Check
Cached(); if true, return immediately (value already computed for this bar). - Calculate the indicator value.
- Call
Appendto 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
TimeStopinvalidates previousCached()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 whenMaxCacheis 1000). - Propagation:
Series.Cutrecursively trims child series in theSubsmap, ensuring that derived indicators like moving averages remain synchronized with their parent series. - Preservation: Retains exactly
MaxCachemost 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:
- Cache retrieval:
obj.To("_sma", period)obtains the dedicated result series for this parameter set. - Short-circuit:
res.Cached()checks ifres.Time >= res.Env.TimeStop, returning immediately if the current bar is already processed. - Computation: Calculates the indicator using helper functions (e.g.,
Sum). - State update:
res.Appendstores 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
Itemsmap containing every series by ID. - Root OHLCV data slices.
- The
Subshierarchy, 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:
BarEnvmaintains all series in theItemsmap, with each series assigned a unique ID viaVNum, providing a single source of truth for indicator state. - Lazy Evaluation: The
Series.Tomethod implements a per-bar cache keyed by indicator name and parameters, ensuring calculations occur only once per unique configuration. - Deterministic Caching:
Series.Cachedvalidates state freshness by comparing the series timestamp againstEnv.TimeStop, preventing redundant computation during indicator chaining. - Memory Management:
TrimOverflowandSeries.Cutautomatically prune historical data beyondMaxCache, recursively trimming derived indicators to maintain synchronization while limiting memory footprint. - State Portability:
CloneandResetToenable 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →