feat: bollinger bands support in rule-based DSL + chart overlay

Add BollingerUpper and BollingerLower as composable FuncName variants,
enabling Bollinger Bands in any expression context (compare, cross_over,
cross_under, apply_func). The multiplier field carries num_std_dev (default
2.0). Chart auto-detects bollinger_upper/lower func nodes and the legacy
bollinger condition, rendering three lines (middle solid, upper/lower dashed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 09:36:05 +02:00
parent 0c1c7c4e85
commit 43c13e68e7
6 changed files with 171 additions and 14 deletions

View File

@@ -39,13 +39,13 @@
},
"FuncName": {
"type": "string",
"enum": ["highest", "lowest", "sma", "ema", "wma", "rsi", "std_dev", "sum", "atr", "supertrend", "adx"],
"description": "Rolling-window function. atr/adx/supertrend ignore 'field' and use OHLC internally."
"enum": ["highest", "lowest", "sma", "ema", "wma", "rsi", "std_dev", "sum", "atr", "supertrend", "adx", "bollinger_upper", "bollinger_lower"],
"description": "Rolling-window function. atr/adx/supertrend ignore 'field' and use OHLC internally. bollinger_upper/bollinger_lower use multiplier for num_std_dev (default 2.0)."
},
"ApplyFuncName": {
"type": "string",
"enum": ["highest", "lowest", "sma", "ema", "wma", "std_dev", "sum"],
"description": "Subset of FuncName valid inside apply_func. atr, supertrend, adx, and rsi are excluded."
"enum": ["highest", "lowest", "sma", "ema", "wma", "std_dev", "sum", "bollinger_upper", "bollinger_lower"],
"description": "Subset of FuncName valid inside apply_func. atr, supertrend, adx, and rsi are excluded. bollinger_upper/bollinger_lower use fixed num_std_dev=2.0 in apply_func context."
},
"CmpOp": {
"type": "string",
@@ -335,7 +335,7 @@
},
"multiplier": {
"$ref": "#/definitions/DecimalString",
"description": "ATR multiplier for supertrend only (e.g. \"3.0\"). Omit for all other functions."
"description": "Numeric multiplier: ATR multiplier for supertrend (e.g. \"3.0\"); num_std_dev for bollinger_upper/bollinger_lower (e.g. \"2.0\", default). Omit for all other functions."
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}

View File

@@ -317,6 +317,12 @@ pub enum FuncName {
/// The `field` parameter is ignored for ADX.
/// Requires approximately `2 * period + 1` candles minimum.
Adx,
/// Upper Bollinger Band: SMA(field, period) + multiplier × StdDev(field, period).
/// [`Expr::Func::multiplier`] = number of standard deviations (default 2.0).
BollingerUpper,
/// Lower Bollinger Band: SMA(field, period) multiplier × StdDev(field, period).
/// [`Expr::Func::multiplier`] = number of standard deviations (default 2.0).
BollingerLower,
}
fn default_close() -> CandleField { CandleField::Close }

View File

@@ -61,6 +61,32 @@ function computeEma(candles: CandleEntry[], field: string, period: number): Line
return result;
}
function computeBollinger(
candles: CandleEntry[],
field: string,
period: number,
multiplier: number,
): { upper: LineData[]; middle: LineData[]; lower: LineData[] } {
const upper: LineData[] = [];
const middle: LineData[] = [];
const lower: LineData[] = [];
const key = field as keyof CandleEntry;
for (let i = period - 1; i < candles.length; i++) {
const values: number[] = [];
for (let j = i - period + 1; j <= i; j++) {
values.push(Number(candles[j][key]));
}
const mean = values.reduce((a, b) => a + b, 0) / period;
const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / period;
const std = Math.sqrt(variance);
const time = (new Date(candles[i].open_time).getTime() / 1000) as UTCTimestamp;
upper.push({ time, value: mean + multiplier * std });
middle.push({ time, value: mean });
lower.push({ time, value: mean - multiplier * std });
}
return { upper, middle, lower };
}
function computeSupertrend(candles: CandleEntry[], period: number, multiplier: number): LineData[] {
if (candles.length < period + 1) return [];
@@ -213,9 +239,42 @@ export default function CandlestickChart({
createSeriesMarkers(candleSeries, buildMarkers(positions));
}
// Render indicators — shared color index across SMA/EMA so colors don't repeat
// Render indicators — shared color index across SMA/EMA/Bollinger so colors don't repeat
let colorIdx = 0;
for (const spec of indicatorSpecs) {
if (spec.kind === 'bollinger') {
const { upper, middle, lower } = computeBollinger(candles, spec.field, spec.period, spec.multiplier);
if (middle.length === 0) continue;
const color = INDICATOR_COLORS[colorIdx % INDICATOR_COLORS.length];
colorIdx++;
const label = `BB(${spec.period},${spec.multiplier})`;
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: true,
title: `${label} Mid`,
}).setData(middle);
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
title: `${label} Upper`,
}).setData(upper);
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
title: `${label} Lower`,
}).setData(lower);
continue;
}
if (spec.kind === 'supertrend') {
const stData = computeSupertrend(candles, spec.period, spec.multiplier);
if (stData.length === 0) continue;

View File

@@ -19,7 +19,15 @@ export interface SupertrendSpec {
timeframe: string | null;
}
export type IndicatorSpec = SmaSpec | EmaSpec | SupertrendSpec;
export interface BollingerSpec {
kind: 'bollinger';
field: string;
period: number;
multiplier: number;
timeframe: string | null;
}
export type IndicatorSpec = SmaSpec | EmaSpec | SupertrendSpec | BollingerSpec;
function walkExpr(expr: unknown, specs: IndicatorSpec[]): void {
if (!expr || typeof expr !== 'object') return;
@@ -44,6 +52,16 @@ function walkExpr(expr: unknown, specs: IndicatorSpec[]): void {
}
return;
}
if (e.name === 'bollinger_upper' || e.name === 'bollinger_lower') {
const field = typeof e.field === 'string' ? e.field : 'close';
const period = typeof e.period === 'number' ? e.period : 0;
const multiplier = typeof e.multiplier === 'string' ? parseFloat(e.multiplier) : 2.0;
const timeframe = typeof e.timeframe === 'string' ? e.timeframe : null;
if (period > 0) {
specs.push({ kind: 'bollinger', field, period, multiplier: isNaN(multiplier) ? 2.0 : multiplier, timeframe });
}
return;
}
return;
}
@@ -84,10 +102,20 @@ function walkCondition(cond: unknown, specs: IndicatorSpec[]): void {
case 'ema_crossover':
case 'ema_trend':
case 'rsi':
case 'bollinger':
case 'position':
case 'price_level':
break;
case 'bollinger': {
const period = typeof c.period === 'number' ? c.period : 0;
const rawMult = c.num_std_dev;
const multiplier = typeof rawMult === 'string'
? parseFloat(rawMult)
: typeof rawMult === 'number' ? rawMult : 2.0;
if (period > 0) {
specs.push({ kind: 'bollinger', field: 'close', period, multiplier: isNaN(multiplier) ? 2.0 : multiplier, timeframe: null });
}
break;
}
}
}
@@ -112,7 +140,9 @@ export function extractIndicatorSpecs(strategyConfig: unknown): IndicatorSpec[]
return raw.filter((s) => {
const key = s.kind === 'supertrend'
? `supertrend:${s.period}:${s.multiplier}:${s.timeframe ?? ''}`
: `${s.kind}:${s.field}:${s.period}:${s.timeframe ?? ''}`;
: s.kind === 'bollinger'
? `bollinger:${s.field}:${s.period}:${s.multiplier}:${s.timeframe ?? ''}`
: `${s.kind}:${s.field}:${s.period}:${s.timeframe ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;

View File

@@ -95,7 +95,8 @@ All expressions have a "kind" discriminator.
"field": "open"|"high"|"low"|"close"|"volume", // which candle field (ignored for atr/adx/supertrend)
"period": <int>,
"offset": <int> // omit or 0 = current bar; 1 = window shifted one bar back
// "multiplier": "<decimal>" — only for supertrend (ATR multiplier, typically "3.0")
// "multiplier": "<decimal>" — for supertrend: ATR multiplier (typically "3.0")
// — for bollinger_upper/bollinger_lower: num_std_dev (typically "2.0")
}
### Bars since a condition last fired
@@ -215,11 +216,14 @@ The API rejects backtest creation if any referenced timeframe lacks coverage.
| rsi | RSI via Wilder's smoothing, result in [0,100] | yes |
| std_dev | population standard deviation | yes |
| sum | rolling sum | yes |
| atr | Average True Range (Wilder simple avg, not smoothed) | NO (uses H/L/C) |
| supertrend | Supertrend line (band-flip, ATR-based) | NO (uses H/L/C) |
| adx | Average Directional Index, Wilder smoothing [0,100] | NO (uses H/L/C) |
| atr | Average True Range (Wilder simple avg, not smoothed) | NO (uses H/L/C) |
| supertrend | Supertrend line (band-flip, ATR-based) | NO (uses H/L/C) |
| adx | Average Directional Index, Wilder smoothing [0,100] | NO (uses H/L/C) |
| bollinger_upper | Upper Bollinger Band: SMA + multiplier×StdDev (multiplier default 2.0) | yes |
| bollinger_lower | Lower Bollinger Band: SMA multiplier×StdDev (multiplier default 2.0) | yes |
atr, supertrend, adx, and rsi are NOT valid inside "apply_func" (return None silently).
bollinger_upper and bollinger_lower ARE valid inside "apply_func" but use a fixed num_std_dev of 2.0 in that context.
## Warm-up / minimum bar counts required before a condition can fire
@@ -228,6 +232,7 @@ atr, supertrend, adx, and rsi are NOT valid inside "apply_func" (return None sil
| sma / ema / wma / sum(N) | N bars |
| rsi(N) | N+1 bars |
| std_dev(N) | N bars |
| bollinger_upper/lower(N) | N bars |
| highest/lowest(N) | N bars |
| atr(N) | N+1 bars |
| adx(N) | 2*N+1 bars |
@@ -284,6 +289,32 @@ Use a candle_interval and backtesting window long enough to cover warm-up.
{ "kind": "position", "state": "flat" }
]}
### Bollinger Bands: close crosses above upper band (breakout entry)
"when": { "kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "bollinger_upper", "field": "close", "period": 20, "multiplier": "2.0" }
}
### Bollinger Bands: close crosses below lower band (mean-reversion entry)
"when": { "kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "bollinger_lower", "field": "close", "period": 20, "multiplier": "2.0" }
}
### Bollinger Bands: price inside the bands (range filter)
"when": { "kind": "all_of", "conditions": [
{ "kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "bollinger_upper", "field": "close", "period": 20, "multiplier": "2.0" }
},
{ "kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "bollinger_lower", "field": "close", "period": 20, "multiplier": "2.0" }
}
]}
### VWAP entry (close crosses above VWAP over last 20 bars)
"when": { "kind": "cross_over",
"left": { "kind": "field", "field": "close" },

View File

@@ -573,7 +573,8 @@ fn eval_func(
match name {
// --- Single-field rolling functions ---
FuncName::Highest | FuncName::Lowest | FuncName::Sma | FuncName::Ema
| FuncName::Rsi | FuncName::StdDev | FuncName::Wma | FuncName::Sum => {
| FuncName::Rsi | FuncName::StdDev | FuncName::Wma | FuncName::Sum
| FuncName::BollingerUpper | FuncName::BollingerLower => {
if history.len() < offset + period {
return None;
}
@@ -640,6 +641,23 @@ fn eval_func(
.map(|c| get_candle_field(c, field))
.sum())
}
FuncName::BollingerUpper | FuncName::BollingerLower => {
let values: Vec<Decimal> = history.iter().skip(start).take(period)
.map(|c| get_candle_field(c, field))
.collect();
let count = Decimal::from(period as u64);
let mean: Decimal = values.iter().sum::<Decimal>() / count;
let variance: Decimal = values.iter()
.map(|&v| { let d = v - mean; d * d })
.sum::<Decimal>() / count;
let std_dev = variance.sqrt()?;
let num_std = multiplier.unwrap_or(Decimal::TWO);
if matches!(name, FuncName::BollingerUpper) {
Some(mean + num_std * std_dev)
} else {
Some(mean - num_std * std_dev)
}
}
_ => unreachable!(),
}
}
@@ -864,6 +882,19 @@ fn eval_apply_func(
.sum::<Decimal>() / count;
variance.sqrt()
}
FuncName::BollingerUpper | FuncName::BollingerLower => {
let count = Decimal::from(period as u64);
let mean: Decimal = values.iter().sum::<Decimal>() / count;
let variance: Decimal = values.iter()
.map(|&v| { let d = v - mean; d * d })
.sum::<Decimal>() / count;
let std_dev = variance.sqrt()?;
if matches!(name, FuncName::BollingerUpper) {
Some(mean + Decimal::TWO * std_dev)
} else {
Some(mean - Decimal::TWO * std_dev)
}
}
// Multi-field or stateful functions: not compatible with scalar series input.
FuncName::Rsi | FuncName::Atr | FuncName::Supertrend | FuncName::Adx => None,
}