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:
@@ -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 }} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user