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:
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user