feat: extend price chart overlays to EMA and Supertrend indicators

Broadens extractSmaSpecs.ts into a general extractIndicatorSpecs utility
that walks the rule DSL and extracts three indicator types:

- SMA: solid line (unchanged)
- EMA: dashed line, same color pool — visually distinct from SMA
- Supertrend: solid magenta (#e040fb), width 2 — prominent line showing
  where the band sits relative to price

Client-side computations added to CandlestickChart:
- computeEma(): standard EMA seeded with SMA of first period values
- computeSupertrend(): Wilder's ATR smoothing + iterative band-flip
  logic matching TradingView's ta.supertrend() behaviour

The walker also descends into event_count conditions and apply_func
inputs (previously missed), and the deprecated extractSmaSpecs export
is kept as a thin wrapper for any future callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:09:05 +02:00
parent 197ac5e659
commit c4f5d60ba9
3 changed files with 199 additions and 41 deletions

View File

@@ -13,9 +13,10 @@ import {
type UTCTimestamp,
} from 'lightweight-charts';
import type { CandleEntry, PaperRunPosition } from '../types/api';
import type { SmaSpec } from '../utils/extractSmaSpecs';
import type { IndicatorSpec } from '../utils/extractSmaSpecs';
const SMA_COLORS = ['#2962ff', '#ff6d00', '#6a1b9a', '#e53935', '#00897b', '#795548'];
const INDICATOR_COLORS = ['#2962ff', '#ff6d00', '#6a1b9a', '#e53935', '#00897b', '#795548'];
const SUPERTREND_COLOR = '#e040fb';
function computeSma(candles: CandleEntry[], field: string, period: number): LineData[] {
const result: LineData[] = [];
@@ -33,6 +34,96 @@ function computeSma(candles: CandleEntry[], field: string, period: number): Line
return result;
}
function computeEma(candles: CandleEntry[], field: string, period: number): LineData[] {
const result: LineData[] = [];
const key = field as keyof CandleEntry;
const k = 2 / (period + 1);
let ema = 0;
let seeded = false;
for (let i = 0; i < candles.length; i++) {
const val = Number(candles[i][key]);
if (!seeded) {
if (i < period - 1) continue;
// Seed with SMA of first `period` values
let sum = 0;
for (let j = i - period + 1; j <= i; j++) sum += Number(candles[j][key]);
ema = sum / period;
seeded = true;
} else {
ema = val * k + ema * (1 - k);
}
result.push({
time: (new Date(candles[i].open_time).getTime() / 1000) as UTCTimestamp,
value: ema,
});
}
return result;
}
function computeSupertrend(candles: CandleEntry[], period: number, multiplier: number): LineData[] {
if (candles.length < period + 1) return [];
// True Range
const tr: number[] = candles.map((c, i) => {
if (i === 0) return c.high - c.low;
return Math.max(
c.high - c.low,
Math.abs(c.high - candles[i - 1].close),
Math.abs(c.low - candles[i - 1].close),
);
});
// ATR via Wilder's smoothing (seed with SMA, then RMA)
const atr: number[] = new Array(candles.length).fill(NaN);
let sum = 0;
for (let i = 0; i < period; i++) sum += tr[i];
atr[period - 1] = sum / period;
for (let i = period; i < candles.length; i++) {
atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period;
}
const upperBand: number[] = new Array(candles.length).fill(NaN);
const lowerBand: number[] = new Array(candles.length).fill(NaN);
// trend: 1 = bearish (on upper band), -1 = bullish (on lower band)
const trend: number[] = new Array(candles.length).fill(0);
const result: LineData[] = [];
for (let i = period - 1; i < candles.length; i++) {
if (isNaN(atr[i])) continue;
const hl2 = (candles[i].high + candles[i].low) / 2;
const basicUpper = hl2 + multiplier * atr[i];
const basicLower = hl2 - multiplier * atr[i];
if (i === period - 1) {
upperBand[i] = basicUpper;
lowerBand[i] = basicLower;
trend[i] = 1; // start bearish until we see a flip
} else {
// Upper band can only decrease; reset if prev close broke above it
upperBand[i] = (basicUpper < upperBand[i - 1] || candles[i - 1].close > upperBand[i - 1])
? basicUpper : upperBand[i - 1];
// Lower band can only increase; reset if prev close broke below it
lowerBand[i] = (basicLower > lowerBand[i - 1] || candles[i - 1].close < lowerBand[i - 1])
? basicLower : lowerBand[i - 1];
if (trend[i - 1] === -1 && candles[i].close < lowerBand[i]) {
trend[i] = 1; // flip to bearish
} else if (trend[i - 1] === 1 && candles[i].close > upperBand[i]) {
trend[i] = -1; // flip to bullish
} else {
trend[i] = trend[i - 1];
}
}
result.push({
time: (new Date(candles[i].open_time).getTime() / 1000) as UTCTimestamp,
value: trend[i] === 1 ? upperBand[i] : lowerBand[i],
});
}
return result;
}
function buildMarkers(positions: PaperRunPosition[]): SeriesMarker<UTCTimestamp>[] {
const markers: SeriesMarker<UTCTimestamp>[] = [];
for (const pos of positions) {
@@ -59,7 +150,6 @@ function buildMarkers(positions: PaperRunPosition[]): SeriesMarker<UTCTimestamp>
size: 1,
});
}
// lightweight-charts requires markers sorted by time
markers.sort((a, b) => (a.time as number) - (b.time as number));
return markers;
}
@@ -67,14 +157,14 @@ function buildMarkers(positions: PaperRunPosition[]): SeriesMarker<UTCTimestamp>
interface Props {
candles: CandleEntry[];
interval: string;
smaSpecs?: SmaSpec[];
indicatorSpecs?: IndicatorSpec[];
positions?: PaperRunPosition[];
}
export default function CandlestickChart({
candles,
interval,
smaSpecs = [],
indicatorSpecs = [],
positions = [],
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -123,20 +213,50 @@ export default function CandlestickChart({
createSeriesMarkers(candleSeries, buildMarkers(positions));
}
smaSpecs.forEach((spec, i) => {
const smaData = computeSma(candles, spec.field, spec.period);
if (smaData.length === 0) return;
const color = SMA_COLORS[i % SMA_COLORS.length];
const smaSeries = chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: true,
title: `SMA(${spec.period})`,
});
smaSeries.setData(smaData);
});
// Render indicators — shared color index across SMA/EMA so colors don't repeat
let colorIdx = 0;
for (const spec of indicatorSpecs) {
if (spec.kind === 'supertrend') {
const stData = computeSupertrend(candles, spec.period, spec.multiplier);
if (stData.length === 0) continue;
chart.addSeries(LineSeries, {
color: SUPERTREND_COLOR,
lineWidth: 2,
lineStyle: LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: true,
title: `ST(${spec.period},${spec.multiplier})`,
}).setData(stData);
continue;
}
const color = INDICATOR_COLORS[colorIdx % INDICATOR_COLORS.length];
colorIdx++;
if (spec.kind === 'sma') {
const lineData = computeSma(candles, spec.field, spec.period);
if (lineData.length === 0) continue;
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: true,
title: `SMA(${spec.period})`,
}).setData(lineData);
} else if (spec.kind === 'ema') {
const lineData = computeEma(candles, spec.field, spec.period);
if (lineData.length === 0) continue;
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: true,
title: `EMA(${spec.period})`,
}).setData(lineData);
}
}
chart.timeScale().fitContent();
chartRef.current = chart;
@@ -145,7 +265,7 @@ export default function CandlestickChart({
chart.remove();
chartRef.current = null;
};
}, [candles, interval, smaSpecs, positions]);
}, [candles, interval, indicatorSpecs, positions]);
return <div ref={containerRef} style={{ height: 400 }} />;
}

View File

@@ -15,7 +15,7 @@ import { usePaperRun, useCancelPaperRun, usePaperRunPositions, usePaperRunCandle
import type { PaperRun } from '../types/api';
import EquityCurveChart from '../components/EquityCurveChart';
import CandlestickChart from '../components/CandlestickChart';
import { extractSmaSpecs } from '../utils/extractSmaSpecs';
import { extractIndicatorSpecs } from '../utils/extractSmaSpecs';
import MetricLabel from '../components/MetricLabel';
import AppBreadcrumb from '../components/AppBreadcrumb';
import mdPnl from '../content/metrics/pnl.md?raw';
@@ -185,11 +185,11 @@ export default function PaperRunDetailPage() {
const { data: positionsData } = usePaperRunPositions(isComplete ? id : undefined, 5000);
const { data: candlesData } = usePaperRunCandles(isComplete ? id : undefined, run?.candle_interval ?? null);
// Extract SMA specs from the strategy config, keeping only those whose timeframe
// Extract indicator specs from the strategy config, keeping only those whose timeframe
// matches the chart's candle interval (null timeframe = primary = matches too).
const smaSpecs = useMemo(() => {
const indicatorSpecs = useMemo(() => {
const cfg = run?.config as Record<string, unknown> | undefined;
const all = extractSmaSpecs(cfg?.strategy);
const all = extractIndicatorSpecs(cfg?.strategy);
return all.filter((s) => s.timeframe === null || s.timeframe === run?.candle_interval);
}, [run?.config, run?.candle_interval]);
@@ -450,7 +450,7 @@ export default function PaperRunDetailPage() {
<CandlestickChart
candles={candlesData.candles}
interval={candlesData.interval}
smaSpecs={smaSpecs}
indicatorSpecs={indicatorSpecs}
positions={positionsData?.positions}
/>
</Card.Body>

View File

@@ -1,19 +1,48 @@
export interface SmaSpec {
kind: 'sma';
field: string;
period: number;
timeframe: string | null; // null = primary timeframe
timeframe: string | null;
}
function walkExpr(expr: unknown, specs: SmaSpec[]): void {
export interface EmaSpec {
kind: 'ema';
field: string;
period: number;
timeframe: string | null;
}
export interface SupertrendSpec {
kind: 'supertrend';
period: number;
multiplier: number;
timeframe: string | null;
}
export type IndicatorSpec = SmaSpec | EmaSpec | SupertrendSpec;
function walkExpr(expr: unknown, specs: IndicatorSpec[]): void {
if (!expr || typeof expr !== 'object') return;
const e = expr as Record<string, unknown>;
if (e.kind === 'func' && e.name === 'sma') {
const field = typeof e.field === 'string' ? e.field : 'close';
const period = typeof e.period === 'number' ? e.period : 0;
const timeframe = typeof e.timeframe === 'string' ? e.timeframe : null;
if (period > 0) {
specs.push({ field, period, timeframe });
if (e.kind === 'func') {
if (e.name === 'sma' || e.name === 'ema') {
const field = typeof e.field === 'string' ? e.field : 'close';
const period = typeof e.period === 'number' ? e.period : 0;
const timeframe = typeof e.timeframe === 'string' ? e.timeframe : null;
if (period > 0) {
specs.push({ kind: e.name as 'sma' | 'ema', field, period, timeframe });
}
return;
}
if (e.name === 'supertrend') {
const period = typeof e.period === 'number' ? e.period : 0;
const multiplier = typeof e.multiplier === 'string' ? parseFloat(e.multiplier) : 3.0;
const timeframe = typeof e.timeframe === 'string' ? e.timeframe : null;
if (period > 0) {
specs.push({ kind: 'supertrend', period, multiplier: isNaN(multiplier) ? 3.0 : multiplier, timeframe });
}
return;
}
return;
}
@@ -22,13 +51,14 @@ function walkExpr(expr: unknown, specs: SmaSpec[]): void {
walkExpr(e.left, specs);
walkExpr(e.right, specs);
walkExpr(e.inner, specs);
walkExpr(e.input, specs);
return;
}
// Field or literal — no SMAs inside
// Field or literal — no indicators inside
}
function walkCondition(cond: unknown, specs: SmaSpec[]): void {
function walkCondition(cond: unknown, specs: IndicatorSpec[]): void {
if (!cond || typeof cond !== 'object') return;
const c = cond as Record<string, unknown>;
@@ -48,26 +78,27 @@ function walkCondition(cond: unknown, specs: SmaSpec[]): void {
walkExpr(c.left, specs);
walkExpr(c.right, specs);
break;
case 'event_count':
walkCondition(c.condition, specs);
break;
case 'ema_crossover':
case 'ema_trend':
case 'rsi':
case 'bollinger':
case 'position':
case 'price_level':
case 'event_count':
// no SMA funcs inside these
break;
}
}
export function extractSmaSpecs(strategyConfig: unknown): SmaSpec[] {
export function extractIndicatorSpecs(strategyConfig: unknown): IndicatorSpec[] {
if (!strategyConfig || typeof strategyConfig !== 'object') return [];
const cfg = strategyConfig as Record<string, unknown>;
if (cfg.type !== 'rule_based') return [];
const rules = Array.isArray(cfg.rules) ? cfg.rules : [];
const raw: SmaSpec[] = [];
const raw: IndicatorSpec[] = [];
for (const rule of rules) {
if (rule && typeof rule === 'object') {
@@ -76,12 +107,19 @@ export function extractSmaSpecs(strategyConfig: unknown): SmaSpec[] {
}
}
// Deduplicate by (field, period, timeframe)
// Deduplicate
const seen = new Set<string>();
return raw.filter((s) => {
const key = `${s.field}:${s.period}:${s.timeframe ?? ''}`;
const key = s.kind === 'supertrend'
? `supertrend:${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;
});
}
/** @deprecated Use extractIndicatorSpecs instead. */
export function extractSmaSpecs(strategyConfig: unknown): SmaSpec[] {
return extractIndicatorSpecs(strategyConfig).filter((s): s is SmaSpec => s.kind === 'sma');
}