Strategy authoring
A strategy declares the history it needs and defines one Python function:
HISTORY = {"primary": 1}
def evaluate(ctx):
...
return None # or a Signal
The engine ingests every bar in order but calls evaluate(ctx) only after every
view declared in HISTORY is ready. From then on, each primary bar gets a fresh
StrategyContext (ctx) describing the market and your
account as of that bar, and returns an optional Signal.
Returning None means "do nothing this bar".
This page is the hand-written reference for that function: the ctx surface you
read, the Signal you return, the rules the sandbox enforces, and the idiomatic
patterns for common needs. For the frozen typed contract (exact signatures,
every field and its type) see the SDK reference; for
complete, runnable strategies see Examples.
Risk is engine-managed
Stops are optional: return Signal(direction="LONG") with no
stop_loss/take_profit for exit-on-signal strategies, then return
Signal(direction="FLAT") when your exit condition fires. The engine sizes
every order and enforces leverage, margin, and kill-switches; you can declare
a default RISK profile and read the resolved values via ctx.risk. See
Risk management.
Names are pre-injected: do not import
Inside evaluate, the names Signal, np (NumPy), pd (pandas), and
math are already in scope. The sandbox injects them. Do not import
anything: imports are rejected by the validator (see
Authoring rules).
The ctx surface
ctx is read-only market and account state. You never mutate it (except
ctx.state, which is yours; see Cross-bar state); you read
from it and decide whether to return a Signal.
Market data
| Field | Type | Notes |
|---|---|---|
ctx.candles |
CandleSeries |
Bounded rolling history up to and including the current bar. |
ctx.candles.close |
pd.Series |
Also .open, .high, .low, .volume, .delta, .buy_volume, .sell_volume. |
ctx.candles.df |
pd.DataFrame |
The current bounded rolling window as a frame, assembled on demand; never the whole run. |
ctx.current_bar |
CandleEvent |
The latest bar: .close, .high, .low, .open, .volume. |
ctx.bar_index |
int |
Absolute 1-based counter for the whole run; never a position into ctx.candles. |
ctx.current_time |
datetime |
Timestamp of the current bar. |
ctx.symbol |
str |
e.g. "BTCUSDT". Constant for the whole run. |
ctx.timeframe |
str |
e.g. "1h". Constant for the whole run. |
ctx.orderbook |
always None |
Strategy-side L2 is disabled in this backend: L2 data drives the fill model, not strategy signals. Build order-flow logic from ctx.candles.delta / .buy_volume / .sell_volume instead. |
ctx.latest_funding_rate |
float or None |
Most recent funding rate, if available. |
The series on ctx.candles are ordinary pandas Series, so the idiomatic way
to read "the value on the current bar" is .iloc[-1]:
def evaluate(ctx):
close = ctx.candles.close
last_close = close.iloc[-1]
...
Declare history instead of guarding it
evaluate() is not called until HISTORY is ready. Size the declaration for
the indicator's period and convergence needs, then index relative to the end
of the rolling window:
HISTORY = {"primary": 100}
def evaluate(ctx):
rsi = ctx.indicators.rsi(ctx.candles.close, 14)
if rsi.iloc[-1] < 30:
...
Indicators
ctx.indicators is a small library of cached indicators. Each takes one or more
pd.Series and returns a pd.Series (unless noted):
| Call | Returns |
|---|---|
ctx.indicators.sma(series, period) |
pd.Series |
ctx.indicators.ema(series, period) |
pd.Series |
ctx.indicators.rsi(series, period=14) |
pd.Series |
ctx.indicators.atr(high, low, close, period=14) |
pd.Series |
ctx.indicators.cvd(delta, offset=0.0) |
pd.Series |
ctx.indicators.choppiness(high, low, close, period=14) |
pd.Series |
ctx.indicators.williams_fractals(high, low, width) |
(fractal_highs, fractal_lows) |
ctx.indicators.keltner(high, low, close, period=20, atr_period=10, mult=2.0) |
(upper, middle, lower) |
ctx.indicators.wma(series, period) |
pd.Series |
ctx.indicators.trima(series, period) |
pd.Series |
ctx.indicators.hull_ma(series, period) |
pd.Series |
ctx.indicators.efficiency_ratio(series, period=10) |
pd.Series (0..1) |
ctx.indicators.kama(series, period=10, fast=2, slow=30) |
pd.Series |
ctx.indicators.williams_r(high, low, close, period=14) |
pd.Series (−100..0) |
ctx.indicators.mfi(high, low, close, volume, period=14) |
pd.Series (0..100) |
ctx.indicators.cmo(series, period=14) |
pd.Series (±100) |
ctx.indicators.stoch_rsi(series, period=14, stoch_period=14, smooth_k=3, smooth_d=3) |
(k, d) |
ctx.indicators.tsi(series, slow=25, fast=13) |
pd.Series (±100) |
ctx.indicators.trix(series, period=15, signal=9) |
(trix, signal) |
ctx.indicators.fisher_transform(high, low, period=10) |
(fisher, signal) |
ctx.indicators.inverse_fisher_rsi(series, period=14, smooth_period=9) |
pd.Series (−1..1) |
ctx.indicators.vwap(high, low, close, volume) |
pd.Series (UTC-session anchored) |
ctx.indicators.rolling_vwap(high, low, close, volume, period=20) |
pd.Series |
ctx.indicators.pivot_points(high, low, close) |
(p, r1, s1, r2, s2) |
ctx.indicators.linreg_slope(series, period=14) |
pd.Series |
ctx.indicators.linreg_forecast(series, period=14, ahead=0) |
pd.Series |
ctx.indicators.linreg_r2(series, period=14) |
pd.Series (0..1) |
ctx.indicators.linreg_channel(series, period=100, num_std=2.0) |
(upper, middle, lower) |
ctx.indicators.parabolic_sar(high, low, af_start=0.02, af_step=0.02, af_max=0.2) |
(sar, direction) |
ctx.indicators.divergence(price, osc, width=2, lookback=90, method="peaks") |
pd.Series (−1/0/+1) |
HISTORY = {"primary": 53}
def evaluate(ctx):
close = ctx.candles.close
fast = ctx.indicators.ema(close, 12)
slow = ctx.indicators.ema(close, 26)
if fast.iloc[-1] > slow.iloc[-1] and ctx.position is None:
return Signal(direction="LONG")
return None
Higher timeframes (HTF)
To read a higher timeframe, call ctx.get_htf(timeframe). It returns a
TimeframeView exposing .candles and .indicators for that timeframe, so you
can compute HTF indicators the same way you compute base-timeframe ones.
Declare every HTF you use
A timeframe is only available if you declare it at module level, above
def evaluate, with additional_timeframes:
HISTORY = {"primary": 1, "4h": 20}
additional_timeframes = ('4h',) # one HTF
# additional_timeframes = ('4h', '1d') # multiple
def evaluate(ctx):
htf = ctx.get_htf('4h')
htf_sma = htf.indicators.sma(htf.candles.close, 20)
...
Calling ctx.get_htf('4h') without declaring it raises
KeyError: No HTF data for '4h'.
Rules enforced by the platform:
- At most 3 additional timeframes per strategy.
- Valid labels:
1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d. - Every declared timeframe must be a strictly-higher exact multiple of
the run's base timeframe: e.g.
'4h'works on a 15m or 1h backtest but not on a 1d backtest. Incompatible submissions are rejected with an instructive error before the run starts. - HTF views are empty until the first complete bucket closes, but you
need no guard for that: give the timeframe a
HISTORYentry (see Declaring history) and the engine withholdsevaluate()until the view holds that many closed buckets.
ctx.htf is the underlying Dict[str, TimeframeView] keyed by your declared
timeframes. Prefer ctx.get_htf(tf) for access; use tf in ctx.htf only for
declared-HTF membership checks.
Base timeframes vs HTF labels are different sets
The base timeframe is the one you pick for the run itself (ctx.timeframe).
The submittable base timeframes are 1m, 5m, 15m, 1h, 4h, 1d. The HTF
labels above (1m … 1d, the full list) are what you may declare as a
higher timeframe, a wider set. The two are independent: a label like 3m
is a legal HTF to declare but not a legal base to run on. 5m is a legal
base timeframe; 5m candles are synthesized live from 1m data (there is no
separate 5m candle store).
Cross-bar state
ctx.state is a plain dict that persists across bars within one run and is
reset between runs. It is yours: the engine never reads or writes it. Use it
for counters, cooldowns, multi-leg flags, peak-equity trackers: anything you
need to remember from one bar to the next.
def evaluate(ctx):
if 'green_count' not in ctx.state:
ctx.state['green_count'] = 0
...
This is the only place your strategy keeps memory: there is no trade history or previous-signal API (see Limits).
Position and portfolio
ctx.position is a PositionView when you hold a position in ctx.symbol, and
None otherwise. (It is a shortcut for ctx.portfolio.position(ctx.symbol).)
if ctx.position is None:
# flat — eligible to enter
...
PositionView fields:
| Field | Notes |
|---|---|
.symbol |
str |
.side |
"LONG" or "SHORT" |
.qty |
float, signed (positive = LONG, negative = SHORT) |
.size |
float, unsigned (= abs(qty)) |
.avg_entry_price |
float |
.unrealized_pnl |
float (USDT) |
.unrealized_pnl_pct |
float already in percent (2.0 means +2%) |
.opened_at |
datetime |
.bars_since_entry |
int |
.stop_loss |
float or None: entry-time SL from the Signal; not live-tracked |
.take_profit |
float or None: entry-time TP; not live-tracked |
.add_count |
int: same-direction DCA merges since open |
ctx.portfolio is the account snapshot across all symbols:
| Field | Notes |
|---|---|
ctx.portfolio.equity |
float: total account equity (USDT) |
ctx.portfolio.cash |
float: available cash (USDT) |
ctx.portfolio.open_orders_count |
int |
ctx.portfolio.open_positions |
tuple of PositionView across all symbols (usually one) |
.stop_loss / .take_profit are the entry levels, not live state
These reflect the SL/TP you set on the entry Signal. .take_profit is
never updated; .stop_loss moves only when the built-in trailing
(trailing_stop_distance) or break-even (breakeven_at_profit_pct)
mechanics ratchet it. The engine itself handles the actual exit when a
level is hit. Reading them is only for your own reasoning (e.g. a
near-stop check).
The Signal contract
Returning a Signal is the only way to act on the engine. Its essential
shape:
Signal(
direction="LONG" | "SHORT" | "FLAT",
stop_loss=<absolute price>,
take_profit=<absolute price>,
confidence=0.0-1.0,
setup_id="my-setup",
)
direction:"LONG","SHORT", or"FLAT"."FLAT"is an explicit exit: it closes the open position forctx.symbolat market (no-op when you are already flat). It is the exit half of an exit-on-signal strategy.stop_loss/take_profit: absolute price levels, not percentages and not distances. A stop at "2% below entry" is computed by you into a price before you pass it. Omit both on a"FLAT"signal.confidence:0.0to1.0, optional.setup_id: a label for the setup, optional.
def evaluate(ctx):
price = ctx.current_bar.close
if entry_condition(ctx) and ctx.position is None:
return Signal(
direction="LONG",
stop_loss=price * 0.98, # absolute price 2% below
take_profit=price * 1.04, # absolute price 4% above
setup_id="breakout",
)
if exit_condition(ctx) and ctx.position is not None:
return Signal(direction="FLAT") # exit-on-signal: close at market
return None
Entry signals can also carry three engine-managed exit mechanics:
max_hold_bars=<N> (time exit: auto-flatten after N bars),
trailing_stop_distance=<absolute distance> (ratcheting trailing stop; requires
stop_loss), and breakeven_at_profit_pct=<N> (stop moves to entry once N% in
profit; requires stop_loss).
For the full constructor, including the additional fields on the typed contract
(order_type, entry_price, add_to_position, risk_pct, metadata), see
Signal in the SDK reference.
SL/TP brackets and adding to a position (DCA)
SL/TP are absolute price levels. When you add to an existing position
with a same-direction Signal that omits SL/TP, the engine resizes the
existing brackets to cover the larger total position but keeps their
absolute prices: the brackets do not re-anchor to the new
weighted-average entry.
To move the brackets after an add, re-specify stop_loss /
take_profit on the add Signal: the protective bracket orders are
cancelled and re-created, re-anchored to the add's fill price (the original
risk/reward distances preserved). Note, however, that
ctx.position.stop_loss / ctx.position.take_profit keep reporting the
original entry's levels: those read-only fields are not updated on an
add, so don't rely on them to reflect the re-anchored orders.
Beware: DCA-ing past your own stop can leave the absolute SL at or through your new average entry: the position can be underwater against a stop that no longer sits where you'd expect relative to your average price.
Authoring rules
The sandbox validates your source before it ever runs. Within evaluate (and
any top-level helper functions):
stop_lossandtake_profitmust be absolute prices (not percentages).evaluate(ctx)returnsOptional[Signal]: aSignal, orNone.- No imports.
np,pd,math,Signal, and theadaptiverisk/stop helpers are pre-injected; everything else is off-limits. UseSignal(risk_pct=...)for a per-signal risk override and theadaptivedispatchers (resolve_stop,resolve_risk,rr_take_profit,atr_trail_distance) for stops/TPs (see Risk management). - No class definitions: only top-level functions.
- No file I/O, no
exec/eval, no network calls.
You may define additional top-level helper functions and call them from
evaluate; the additional_timeframes declaration (see
HTF) also lives at module level.
Declaring history
Every strategy must declare how much history it needs, as a module-level
HISTORY literal dict above def evaluate. One entry per view you read, each
count in that view's own bars:
HISTORY = {"primary": 200} # primary timeframe only
HISTORY = {"primary": 200, "4h": 50} # also reads ctx.get_htf("4h")
"primary"is required: the largest number of primary-timeframe bars your logic needs (your biggest indicator period plus warm-up).- Each higher-timeframe entry is the bars that view needs. An
EMA(50)on 4h is"4h": 50, never pre-multiplied by the timeframe ratio. The engine scales it for whichever base timeframe the run selects, which is why the declaration stays runnable on every compatible base. - Declare an entry for every timeframe in
additional_timeframes, and no others. The two sets must match exactly: a declared higher timeframe with noHISTORYentry, or aHISTORYentry for a timeframe you never declared, is a validation error. LOOKBACK = Nis accepted as shorthand forHISTORY = {"primary": N}. Declare one form or the other, never both.
Why it is mandatory. ctx.candles is a bounded rolling window and the
engine sizes it (and its activation gate) from this declaration. Without one
the window defaults to 200 bars and never exceeds 250, so a guard like
if len(ctx.candles) < 300 can never open and the strategy silently never
trades while the run still reports success. A missing or malformed declaration
is a hard validation error, and the submit endpoint also refuses a date range
that cannot hold the requirement.
No warm-up guard needed. The engine withholds evaluate() until every
declared view holds the bars you declared: the primary window and each higher
timeframe, each counted in its own bars. Declare the number and read history
directly:
HISTORY = {"primary": 300, "4h": 50}
additional_timeframes = ("4h",)
def evaluate(ctx):
# 300 primary bars and 50 closed 4h buckets are guaranteed here.
ema = ctx.indicators.ema(ctx.candles.close, 200)
htf = ctx.get_htf("4h")
slow = htf.indicators.sma(htf.candles.close, 50)
...
Reading further back than you declared still raises (the window is sized from
the same number), and a len() guard demanding more than you declared is a
validation error, because it could never open.
ctx.bar_index keeps counting the withheld bars: it is the absolute 1-based
count of bars the engine has ingested, so it is already ≥ your requirement on
the very first call. It is still not a position into ctx.candles.
Limits. Two separate ceilings:
- Each entry is an int in
[1, 3000]: that bounds one view's rolling window. - The requirement resolved on an hourly base must stay within 5000. That is the budget of the pre-run validation harness, which drives your strategy to steady state before you ever submit it; a declaration it could not exercise would be shipped unvalidated.
So {"primary": 200, "1d": 200} (4800 hourly bars) is accepted, since it only
allocates 200 bars per view, while {"1d": 250} (6000) is rejected. Use a
lower higher timeframe or a smaller period.
Parameters & forward-walk (PARAMS)
A strategy can declare optimizable parameters by assigning a module-level
PARAMS dict above def evaluate. Each parameter has a default and an
optional sweep, a list of candidate values. Declaring at least one sweep
list makes the strategy forward-walkable: forward-walk optimization searches
the cartesian product of every sweep, picks the best parameters per in-sample
window, and validates them out-of-sample. A strategy with no swept
parameter cannot be forward-walked.
PARAMS = {
"fast": {"default": 12, "sweep": [8, 12, 16]},
"slow": {"default": 26, "sweep": [21, 26, 34]},
}
HISTORY = {"primary": 69} # two periods of the largest swept EMA (34), plus one
def evaluate(ctx):
fast_n = ctx.params["fast"] # forward-walk substitutes a swept value;
slow_n = ctx.params["slow"] # a plain backtest uses the declared default.
...
You read parameter values via ctx.params["name"], a read-only mapping.
Outside of forward-walk (a plain backtest), ctx.params holds each parameter's
default. During forward-walk, the engine substitutes the chosen sweep value
for that window. Builtin strategies expose ctx.params == {}.
The PARAMS contract
| Rule | Detail |
|---|---|
| Shape | PARAMS = {"name": {"default": X, "sweep": [...]}}. sweep is optional: omit it to declare a fixed (non-optimized) parameter. |
| Literal only | PARAMS must be a plain literal dict (no function calls, names, or comprehensions). It is read without executing your code. |
| Value types | Each default is an int, float, or bool. Every value in a sweep must match the default's type exactly (bool is not interchangeable with int). |
| Names | Lowercase identifier, max 31 chars (^[a-z_][a-z0-9_]{0,30}$). |
lookback reserved |
lookback is not a valid parameter name: it is reserved by the engine. |
| Limits | At most 8 parameters; each sweep holds 1-25 unique values. |
| Forward-walkable | At least one parameter must have a sweep list, or forward-walk is rejected at submission. |
Grid size is capped per tier
The grid size is the product of all sweep lengths (e.g. two 3-value
sweeps = 9 combinations). The maximum grid size allowed in a single
forward-walk is capped by your subscription tier. Keep sweeps small;
the forward-walk modal previews the combination count before you submit.
No-code parameters
No-code strategies declare the same parameters by adding a sweep list to a
numeric field in the config. The compiler turns each swept field into a PARAMS
entry and rewrites the field to read ctx.params[...], so a no-code strategy
flows through the same forward-walk pipeline as a Python one. The fields that
accept a sweep and the parameter name each one produces:
| Config field | Sweep key | Parameter name |
|---|---|---|
indicators[i].period |
indicators[i].sweep |
<indicator_id>_period (lowercased id) |
exit_config.stop_loss_value |
exit_config.stop_loss_value_sweep |
stop_loss_value |
exit_config.take_profit_value |
exit_config.take_profit_value_sweep |
take_profit_value |
exit_config.atr_period |
exit_config.atr_period_sweep |
atr_period |
condition constant RHS {"value": X} |
{"value": X, "sweep": [...]} |
<side>_c<idx>_rhs (side = long/short; idx = 0-based position among that side's leaf conditions) |
Periods and atr_period sweep over integers; stop_loss_value,
take_profit_value, and condition RHS values sweep over floats. Unswept
fields stay literal. See the parameterized
EMA crossover example for the
Python form.
No-code higher timeframes
Any no-code indicator can read a higher timeframe by adding an optional
"timeframe" field. It works on named indicators and on the inline dict
on the right-hand side of a condition. The compiler derives the
additional_timeframes declaration from use and routes each tagged indicator
through that timeframe's view. You do not declare anything separately.
Here a 4h EMA trend filter gates a base-timeframe EMA crossover. The first
condition is base-timeframe; the second compares a named 4h EMA against an
inline 4h EMA on the right-hand side (both carry "timeframe": "4h"):
{
"lookback": 200,
"indicators": [
{"id": "ema_fast", "type": "ema", "series": "close", "period": 20},
{"id": "ema_slow", "type": "ema", "series": "close", "period": 50},
{"id": "trend_4h", "type": "ema", "series": "close", "period": 50,
"timeframe": "4h"}
],
"long_entry": {
"logic": "AND",
"conditions": [
{"left": "ema_fast", "op": ">", "right": "ema_slow"},
{"left": "trend_4h", "op": ">",
"right": {"type": "ema", "series": "close", "period": 200,
"timeframe": "4h"}}
]
}
}
The same constraints as the Python additional_timeframes contract apply
(they share one source of truth):
- Valid labels:
1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d. - At most 3 distinct higher timeframes across the whole config (counted by distinct label, not by indicator).
- Every higher timeframe must be a strictly-higher exact multiple of the run's base timeframe (see Higher timeframes).
Activation waits for every declared view
Higher-timeframe views start empty, but the no-code compiler derives their
HISTORY requirements and the engine withholds strategy evaluation until
every required bucket is closed. You do not add length checks in the config.
Incompatible base/HTF is rejected at submit time
If a declared higher timeframe cannot aggregate from the base timeframe you pick for the run, the run is rejected before it starts with a 422, e.g.:
Strategy declares additional timeframe(s) '4h' that cannot aggregate from
the '1d' base timeframe — an HTF must be a strictly-higher exact multiple
of the base. Compatible base timeframes for this strategy: 1m, 5m, 15m, 1h.
The message lists the base timeframes that would work for the strategy. Backtest and forward-walk return the byte-identical message.
Idiomatic patterns
Common needs and the clean way to express them with the surface above.
Buy on the Nth consecutive green candle. Count in ctx.state:
def evaluate(ctx):
if 'green_count' not in ctx.state:
ctx.state['green_count'] = 0
if ctx.current_bar.close > ctx.current_bar.open:
ctx.state['green_count'] += 1
else:
ctx.state['green_count'] = 0
if ctx.state['green_count'] >= N and ctx.position is None:
return Signal(direction="LONG")
return None
Skip bars while already in a position. Bail early:
if ctx.position is not None:
return None
Reference sizing off equity. The engine sizes orders from your risk config; this is only for reasoning about stop width, not for placing orders:
# Risk ~1% of equity per trade (reference only — engine executes your SL/TP).
stop_dist = abs(price - sl)
reference_qty = (ctx.portfolio.equity * 0.01) / stop_dist
Wait M bars after a stop-out before re-entering. There is no stop-out event, so detect the in-trade → flat transition yourself and store a cooldown bar:
def evaluate(ctx):
prev = ctx.state.get('prev_position')
if prev is not None and ctx.position is None:
# transitioned from in-trade -> flat this bar: stop-out or TP hit
ctx.state['cooldown_until'] = ctx.bar_index + M
ctx.state['prev_position'] = ctx.position
if ctx.position is None and ctx.bar_index >= ctx.state.get('cooldown_until', 0):
# eligible to enter
...
return None
Higher-timeframe trend filter. Declare the HTF, then gate on it:
HISTORY = {"primary": 1, "4h": 20}
additional_timeframes = ('4h',)
def evaluate(ctx):
htf = ctx.get_htf("4h")
htf_sma = htf.indicators.sma(htf.candles.close, 20)
if htf_sma.iloc[-1] > htf.candles.close.iloc[-1]:
return None
...
Near-stop check (read-only; the engine handles the actual stop hit):
if ctx.position and ctx.position.stop_loss is not None:
dist_pct = abs(price - ctx.position.stop_loss) / price
if dist_pct < 0.005:
return None
See Examples for these assembled into complete strategies.
Limits: NOT in the API
These do not exist. Don't reach for them; write your strategy without them.
- Arbitrary mid-trade stop modification: not supported. Brackets are set on
the entry
Signal; the only mid-trade movers are the built-in trailing (trailing_stop_distance) and break-even (breakeven_at_profit_pct) mechanics, plus re-specifying SL/TP on a DCA add. For time-based exits usemax_hold_bars; for dynamic/indicator exits returnSignal(direction="FLAT")(see theSignalcontract). ctx.history/ctx.trades/ctx.previous_signals: not exposed. Usectx.stateto remember what you care about (last entry bar, cumulative win count, previous-position snapshot, …).ctx.broker/ctx.cancel_order/ctx.modify_position/ctx.modify_stop: not exposed. TheSignalyou return is the only way to interact with the engine.ctx.portfolio.drawdown_pct: not exposed. Compute it fromctx.portfolio.equityplus a peak-equity tracker you keep inctx.state.- A "just got stopped out" event: there isn't one. Detect it by watching
ctx.positiontransition from non-NonetoNoneacross bars (store the previous bar'sctx.positioninctx.state).
Implementation note (as of M5d)
The candle store backing ctx.candles.* is a NumPy ring buffer
(CandleRingBuffer). CandleView builds pd.Series lazily and caches them
per bar over a shared DatetimeIndex; the lazy .df property assembles the
full DataFrame on demand.
The public API is unchanged: ctx.candles.close, ctx.candles.df,
.iloc, .name, and everything else above behave identically to before.
Forward-walk runs on the same BacktestRunner path and inherits the ring
buffer automatically.
You do not need to change anything in your strategy: the ring buffer is a pure performance change behind a stable surface.