From c4f5d60ba9449c90798c969fa78a559fe3776d6e Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Fri, 6 Mar 2026 18:09:05 +0200 Subject: [PATCH] feat: extend price chart overlays to EMA and Supertrend indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard/src/components/CandlestickChart.tsx | 160 +++++++++++++++--- dashboard/src/pages/PaperRunDetailPage.tsx | 10 +- dashboard/src/utils/extractSmaSpecs.ts | 70 ++++++-- 3 files changed, 199 insertions(+), 41 deletions(-) diff --git a/dashboard/src/components/CandlestickChart.tsx b/dashboard/src/components/CandlestickChart.tsx index 5edd8c5..16786ea 100644 --- a/dashboard/src/components/CandlestickChart.tsx +++ b/dashboard/src/components/CandlestickChart.tsx @@ -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[] { const markers: SeriesMarker[] = []; for (const pos of positions) { @@ -59,7 +150,6 @@ function buildMarkers(positions: PaperRunPosition[]): SeriesMarker 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 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(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
; } diff --git a/dashboard/src/pages/PaperRunDetailPage.tsx b/dashboard/src/pages/PaperRunDetailPage.tsx index 420b635..fc07dcd 100644 --- a/dashboard/src/pages/PaperRunDetailPage.tsx +++ b/dashboard/src/pages/PaperRunDetailPage.tsx @@ -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 | 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() { diff --git a/dashboard/src/utils/extractSmaSpecs.ts b/dashboard/src/utils/extractSmaSpecs.ts index d52c6f5..e00e7c4 100644 --- a/dashboard/src/utils/extractSmaSpecs.ts +++ b/dashboard/src/utils/extractSmaSpecs.ts @@ -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; - 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; @@ -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; 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(); 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'); +}