// TradingView Lightweight Charts wrapper.
// Owns chart lifecycle, data sync, toolbar, trade markers, and loading state.
// Props (read from TerminalVariant):
// - priceRows: array of {ts, open, high, low, close, volume} (strings or numbers accepted)
// - indicatorsByPane: {price: [...], subpane_1: [...], subpane_2: [...]}
// - trades: array of trade dicts (see spec §4.6)
// - fetchStatus: 'idle' | 'loading' | 'slow-loading'
// - fetchContext: {mode, symbol, timeframe, rangeLabel, strategy} — used in the slow-loading overlay
// mode 'preview' shows "LOADING BARS · SYM/TF · RANGE"
// mode 'run' shows "RUNNING BACKTEST · STRATEGY"
// - dateRange: [startISO, endISO]
// - onRangeChange: (newDateRange) => void
// - theme: object of color tokens {fg, bg, bg2, line, dim, green, amber, red}
//
// TODO(tech-debt): decompose into:
// - trading-chart/chart-core.jsx (main chart lifecycle + panes)
// - trading-chart/chart-toolbar.jsx (RANGE presets + OPTIONS dropdown)
// - trading-chart/chart-primitives.jsx (VerticalLinePrimitive + related)
// - trading-chart/chart-loader.jsx (adaptive progress bar + overlay)
// Current file is ~1000 lines; split when any pane type or primitive is added.
// Accept OHLCV as either string (legacy run payload from bootstrap.py) or number
// (new /api/bars payload). LWC needs numbers.
const toNum = (v) => (v === null || v === undefined ? null : typeof v === 'number' ? v : parseFloat(v));
// LWC time format: UNIX seconds for intraday, business-day object for daily.
// We use UNIX seconds uniformly — it's valid for all timeframes.
const toUnixSec = (iso) => Math.floor(new Date(iso).getTime() / 1000);
// Map [{ts, open, high, low, close}] -> LWC candlestick rows.
const mapCandles = (rows) =>
(rows || [])
.map((r) => ({
time: toUnixSec(r.ts),
open: toNum(r.open),
high: toNum(r.high),
low: toNum(r.low),
close: toNum(r.close),
}))
.filter(
(r) =>
Number.isFinite(r.time) &&
Number.isFinite(r.open) &&
Number.isFinite(r.high) &&
Number.isFinite(r.low) &&
Number.isFinite(r.close)
);
// Heikin-Ashi candle conversion. Each HA candle depends on the previous HA candle's open+close.
const toHeikinAshi = (candles) => {
const out = [];
let prev = null;
for (const c of candles) {
const haClose = (c.open + c.high + c.low + c.close) / 4;
const haOpen = prev ? (prev.open + prev.close) / 2 : (c.open + c.close) / 2;
const haHigh = Math.max(c.high, haOpen, haClose);
const haLow = Math.min(c.low, haOpen, haClose);
const ha = { time: c.time, open: haOpen, high: haHigh, low: haLow, close: haClose };
out.push(ha);
prev = ha;
}
return out;
};
// Map ts-aligned series (indicator values array) -> LWC line series rows, skipping nulls.
const mapIndicatorLine = (rows, values) =>
(rows || [])
.map((r, i) => ({ time: toUnixSec(r.ts), value: toNum(values[i]) }))
.filter((p) => Number.isFinite(p.time) && Number.isFinite(p.value));
// Expand one trade into two events (entry + exit) with label and color.
// Trade payload shape (from bootstrap.py): {id, side, entry_ts, exit_ts, entry, exit, pnl_pct, ...}
// side is serialized as "buy" / "sell" (OrderSide enum values) — treat "buy" as long, "sell" as short.
const expandTradeEvents = (trade) => {
const isLong = window.ChartUtils.isLongSide(trade.side || 'buy');
const entryPx = toNum(trade.entry);
const exitPx = toNum(trade.exit);
const pnlPct = toNum(trade.pnl_pct);
const entry = {
type: 'entry',
isLong,
time: toUnixSec(trade.entry_ts),
price: entryPx,
label: `${isLong ? 'BUY' : 'SELL'} ${window.ChartUtils.fmt(entryPx, 2)}`,
color: isLong ? 'green' : 'red',
};
const exit = {
type: 'exit',
isLong,
time: toUnixSec(trade.exit_ts),
price: exitPx,
// pnl_pct from trade.py is already in percent form (e.g. 34.38 = 34.38%), not a fraction.
label:
Number.isFinite(pnlPct)
? `${isLong ? 'SELL' : 'BUY'} ${pnlPct >= 0 ? '+' : ''}${window.ChartUtils.fmt(Math.abs(pnlPct), 2)}%`
: isLong ? 'SELL' : 'BUY',
color: Number.isFinite(pnlPct) && pnlPct < 0 ? 'red' : 'green',
};
return [entry, exit];
};
// Full-height dashed vertical line primitive for LWC v5 ISeriesPrimitive API.
// Attached to a series; draws on that series' pane at a given timestamp.
class VerticalLinePrimitive {
constructor(options) {
this._options = options; // { time, color, lineWidth, dashed }
this._chart = null;
this._paneViews = [new VerticalLinePaneView(this)];
}
attached({ chart }) {
this._chart = chart;
}
detached() {
this._chart = null;
}
updateAllViews() {
for (const v of this._paneViews) v.update();
}
paneViews() {
return this._paneViews;
}
}
class VerticalLinePaneView {
constructor(source) {
this._source = source;
this._x = null;
// Cache renderer to avoid allocating a new instance on every repaint (60fps × N primitives).
this._renderer = new VerticalLineRenderer(source, null);
}
update() {
const src = this._source;
if (!src._chart) { this._x = null; this._renderer._x = null; return; }
const ts = src._chart.timeScale();
this._x = ts.timeToCoordinate(src._options.time);
this._renderer._x = this._x;
}
renderer() {
return this._renderer;
}
}
class VerticalLineRenderer {
constructor(source, x) {
this._source = source;
this._x = x;
}
draw(target) {
if (this._x === null) return;
const opts = this._source._options;
target.useMediaCoordinateSpace((scope) => {
const { context: ctx, mediaSize } = scope;
ctx.save();
ctx.strokeStyle = opts.color;
ctx.lineWidth = opts.lineWidth || 1;
if (opts.dashed) ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(this._x, 0);
ctx.lineTo(this._x, mediaSize.height);
ctx.stroke();
ctx.restore();
});
}
}
// Mode constants — avoid stringly-typed comparisons throughout this file and variant-terminal.jsx.
// Exposed via window.ChartConstants so variant-terminal.jsx can reference them without duplication.
const CHART_TYPE = { CANDLE: 'candle', LINE: 'line', HEIKIN: 'heikin' };
const SCALE_MODE = { LINEAR: 0, LOG: 1 };
const FETCH_STATUS = { IDLE: 'idle', LOADING: 'loading', SLOW: 'slow-loading' };
const FETCH_MODE = { PREVIEW: 'preview', RUN: 'run' };
window.ChartConstants = { CHART_TYPE, SCALE_MODE, FETCH_STATUS, FETCH_MODE };
const RANGE_PRESETS = [
{ id: '1D', days: 1 },
{ id: '1W', days: 7 },
{ id: '1M', days: 30 },
{ id: '3M', days: 90 },
{ id: '6M', days: 180 },
{ id: '1Y', days: 365 },
];
const TradingChartToolbar = ({
dateRange,
onRangeChange,
theme,
openOptions,
onOptionsToggle,
onFit,
onReset,
}) => {
const [startISO, endISO] = dateRange || [null, null];
const activePreset = React.useMemo(() => {
if (!startISO || !endISO) return null;
const ms = new Date(endISO).getTime() - new Date(startISO).getTime();
const days = Math.round(ms / (1000 * 60 * 60 * 24));
return RANGE_PRESETS.find((p) => p.days === days)?.id || null;
}, [startISO, endISO]);
const applyPreset = (days) => {
if (!endISO) return;
const end = new Date(endISO);
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - days);
onRangeChange([start.toISOString().slice(0, 10), endISO]);
};
return (
RANGE:
{RANGE_PRESETS.map((p) => (
))}
);
};
const TradingChart = (props) => {
const { dateRange, onRangeChange, theme } = props;
const [openOptions, setOpenOptions] = React.useState(false);
const [chartType, setChartType] = React.useState(CHART_TYPE.CANDLE); // candle | line | heikin
const [scaleMode, setScaleMode] = React.useState(SCALE_MODE.LINEAR); // 0 Normal/Linear, 1 Log
const [showVolume, setShowVolume] = React.useState(false);
const [seriesVisibility, setSeriesVisibility] = React.useState({
supertrend: true,
crsi_fast: true,
crsi_slow: true,
});
const [priceSeriesVersion, setPriceSeriesVersion] = React.useState(0);
const mainContainerRef = React.useRef(null);
const mainChartRef = React.useRef(null);
const priceSeriesRef = React.useRef({ kind: null, series: null });
const supertrendSeriesRef = React.useRef(null);
const crsiFastSeriesRef = React.useRef(null);
const crsiSlowSeriesRef = React.useRef(null);
const crsiSlowLbSeriesRef = React.useRef(null);
const crsiSlowUbSeriesRef = React.useRef(null);
const crsiFastLbSeriesRef = React.useRef(null);
const crsiFastUbSeriesRef = React.useRef(null);
const crsiSignalsMarkersRef = React.useRef(null);
const volumeSeriesRef = React.useRef(null);
// v5 API: createSeriesMarkers returns a handle with setMarkers() and detach(); no series.setMarkers().
const priceMarkersHandleRef = React.useRef(null);
// Tracks { targetSeries, primitive } pairs for cleanup on re-render / unmount.
const verticalPrimitivesRef = React.useRef([]);
// Install (or swap) the price series for the given chart type.
const installPriceSeries = (kind) => {
const chart = mainChartRef.current;
if (!chart) return;
if (priceSeriesRef.current.series) {
try { chart.removeSeries(priceSeriesRef.current.series); } catch (e) { console.warn('installPriceSeries: removeSeries failed:', e); }
priceSeriesRef.current = { kind: null, series: null };
}
let s;
if (kind === CHART_TYPE.CANDLE || kind === CHART_TYPE.HEIKIN) {
s = chart.addSeries(window.LightweightCharts.CandlestickSeries, {
upColor: theme.green,
downColor: theme.red,
borderUpColor: theme.green,
borderDownColor: theme.red,
wickUpColor: theme.green,
wickDownColor: theme.red,
});
} else {
s = chart.addSeries(window.LightweightCharts.LineSeries, {
color: theme.green,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: true,
});
}
priceSeriesRef.current = { kind, series: s };
};
// Push price data for the currently installed series kind.
const pushPriceData = () => {
const { kind, series } = priceSeriesRef.current;
if (!series) return;
if (kind === CHART_TYPE.CANDLE) {
series.setData(mapCandles(props.priceRows));
} else if (kind === CHART_TYPE.HEIKIN) {
series.setData(toHeikinAshi(mapCandles(props.priceRows)));
} else {
series.setData(
(props.priceRows || [])
.map((r) => ({ time: toUnixSec(r.ts), value: toNum(r.close) }))
.filter((p) => Number.isFinite(p.time) && Number.isFinite(p.value))
);
}
};
// Create main chart on mount.
React.useEffect(() => {
const el = mainContainerRef.current;
if (!el || !window.LightweightCharts) return;
const chart = window.LightweightCharts.createChart(el, {
layout: {
background: { color: theme.bg },
textColor: theme.fg,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
},
grid: {
vertLines: { color: theme.line, style: 1 },
horzLines: { color: theme.line, style: 1 },
},
rightPriceScale: { borderColor: theme.line },
timeScale: { borderColor: theme.line, timeVisible: true, secondsVisible: false },
crosshair: { mode: 1 }, // Magnet
autoSize: true,
});
mainChartRef.current = chart;
installPriceSeries(CHART_TYPE.CANDLE);
supertrendSeriesRef.current = chart.addSeries(window.LightweightCharts.LineSeries, {
color: theme.amber,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
});
// Pane 1: cRSI 0-200 pane. Both main lines in white (fg), red bands for cRSI1
// (0-100, slow series), orange bands for cRSI2 (100-200, fast series).
// addPane() with no args (v5 API expects optional PaneOptions, not int).
chart.addPane();
// Main lines — white so they stand out on the colored band background.
crsiFastSeriesRef.current = chart.addSeries(
window.LightweightCharts.LineSeries,
{ color: theme.fg, lineWidth: 1.5, priceLineVisible: false, lastValueVisible: false },
1,
);
crsiSlowSeriesRef.current = chart.addSeries(
window.LightweightCharts.LineSeries,
{ color: theme.fg, lineWidth: 1.5, priceLineVisible: false, lastValueVisible: false },
1,
);
// Red bands around cRSI1 slow series (0-100 scale).
crsiSlowLbSeriesRef.current = chart.addSeries(
window.LightweightCharts.LineSeries,
{ color: '#f32121', lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
1,
);
crsiSlowUbSeriesRef.current = chart.addSeries(
window.LightweightCharts.LineSeries,
{ color: '#f32121', lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
1,
);
// Orange bands around cRSI2 fast series (100-200 scale, offset +100).
crsiFastLbSeriesRef.current = chart.addSeries(
window.LightweightCharts.LineSeries,
{ color: '#ffa500', lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
1,
);
crsiFastUbSeriesRef.current = chart.addSeries(
window.LightweightCharts.LineSeries,
{ color: '#ffa500', lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
1,
);
// Approximate 65/35 split. Stretch factor is relative; sum is normalized by LWC.
const panes = chart.panes();
if (panes.length >= 2) {
panes[0].setStretchFactor(65);
panes[1].setStretchFactor(35);
}
// Lock cRSI pane Y-axis to exactly 0-200 (same trick Pine uses: force the
// range regardless of data that may spike outside during warmup/edge cases).
// Every series in pane 1 returns the same price range so the union equals
// that range — no series can push the scale wider.
const force0to200 = () => ({ priceRange: { minValue: 0, maxValue: 200 } });
for (const ref of [
crsiFastSeriesRef,
crsiSlowSeriesRef,
crsiSlowLbSeriesRef,
crsiSlowUbSeriesRef,
crsiFastLbSeriesRef,
crsiFastUbSeriesRef,
]) {
ref.current?.applyOptions({ autoscaleInfoProvider: force0to200 });
}
return () => {
chart.remove();
mainChartRef.current = null;
priceSeriesRef.current = { kind: null, series: null };
supertrendSeriesRef.current = null;
crsiFastSeriesRef.current = null;
crsiSlowSeriesRef.current = null;
crsiSlowLbSeriesRef.current = null;
crsiSlowUbSeriesRef.current = null;
crsiFastLbSeriesRef.current = null;
crsiFastUbSeriesRef.current = null;
crsiSignalsMarkersRef.current = null;
volumeSeriesRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // create once; theme changes handled via options update below
// Swap price series when chart type changes.
React.useEffect(() => {
if (!mainChartRef.current) return;
installPriceSeries(chartType);
// Re-bind data for the new series kind.
pushPriceData();
// Bump the version so markers effect re-runs and re-attaches markers to new series.
setPriceSeriesVersion((v) => v + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartType]);
// Apply theme to live chart when it changes.
React.useEffect(() => {
const chart = mainChartRef.current;
if (!chart) return;
chart.applyOptions({
layout: { background: { color: theme.bg }, textColor: theme.fg },
grid: {
vertLines: { color: theme.line },
horzLines: { color: theme.line },
},
rightPriceScale: { borderColor: theme.line },
timeScale: { borderColor: theme.line },
});
const { kind, series: priceSeries } = priceSeriesRef.current;
if (priceSeries) {
if (kind === CHART_TYPE.CANDLE || kind === CHART_TYPE.HEIKIN) {
priceSeries.applyOptions({
upColor: theme.green,
downColor: theme.red,
borderUpColor: theme.green,
borderDownColor: theme.red,
wickUpColor: theme.green,
wickDownColor: theme.red,
});
} else {
priceSeries.applyOptions({ color: theme.green });
}
}
supertrendSeriesRef.current?.applyOptions({ color: theme.amber });
// cRSI main lines track theme foreground for visibility in both dark/bright modes.
// Band series keep their fixed red/orange colors per the Pine Script spec.
crsiFastSeriesRef.current?.applyOptions({ color: theme.fg });
crsiSlowSeriesRef.current?.applyOptions({ color: theme.fg });
}, [theme.bg, theme.fg, theme.line, theme.green, theme.red, theme.amber]);
// Push price + Supertrend data whenever inputs change.
React.useEffect(() => {
const { series: priceSeries } = priceSeriesRef.current;
const supertrendSeries = supertrendSeriesRef.current;
if (!priceSeries || !supertrendSeries) return;
pushPriceData();
const supertrend = (props.indicatorsByPane?.price || []).find((i) => i.name === 'supertrend');
// Bicolor Supertrend: green when line is below price (uptrend, line = lower band),
// red when line is above price (downtrend, line = upper band). LWC v5 LineData
// supports per-point `color` that overrides the series default for that segment.
if (supertrend && supertrend.values && props.priceRows && props.priceRows.length) {
const points = [];
for (let i = 0; i < props.priceRows.length; i++) {
const row = props.priceRows[i];
const stVal = toNum(supertrend.values[i]);
if (!Number.isFinite(stVal)) continue;
const close = toNum(row.close);
if (!Number.isFinite(close)) continue;
points.push({
time: toUnixSec(row.ts),
value: stVal,
color: stVal < close ? theme.green : theme.red,
});
}
supertrendSeries.setData(points);
} else {
supertrendSeries.setData([]);
}
const subpane1 = props.indicatorsByPane?.subpane_1 || [];
const crsiFast = subpane1.find((i) => i.name === 'crsi_fast');
const crsiSlow = subpane1.find((i) => i.name === 'crsi_slow');
crsiFastSeriesRef.current?.setData(
crsiFast && crsiFast.values ? mapIndicatorLine(props.priceRows, crsiFast.values) : []
);
crsiSlowSeriesRef.current?.setData(
crsiSlow && crsiSlow.values ? mapIndicatorLine(props.priceRows, crsiSlow.values) : []
);
// cRSI band series — fixed red/orange per Pine Script (no theme recolor).
const slowLb = subpane1.find((i) => i.name === 'crsi_slow_lb');
const slowUb = subpane1.find((i) => i.name === 'crsi_slow_ub');
const fastLb = subpane1.find((i) => i.name === 'crsi_fast_lb');
const fastUb = subpane1.find((i) => i.name === 'crsi_fast_ub');
crsiSlowLbSeriesRef.current?.setData(
slowLb?.values ? mapIndicatorLine(props.priceRows, slowLb.values) : []
);
crsiSlowUbSeriesRef.current?.setData(
slowUb?.values ? mapIndicatorLine(props.priceRows, slowUb.values) : []
);
crsiFastLbSeriesRef.current?.setData(
fastLb?.values ? mapIndicatorLine(props.priceRows, fastLb.values) : []
);
crsiFastUbSeriesRef.current?.setData(
fastUb?.values ? mapIndicatorLine(props.priceRows, fastUb.values) : []
);
if (props.priceRows && props.priceRows.length > 0) {
mainChartRef.current?.timeScale().fitContent();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.priceRows, props.indicatorsByPane, theme.green, theme.red]);
// Render BOTH/SLOW signal markers on the cRSI slow series (subpane_1).
// White arrow = BOTH (both series fired), maroon arrow = SLOW (slow series only).
React.useEffect(() => {
const crsiSlowSeries = crsiSlowSeriesRef.current;
if (!crsiSlowSeries) return;
// Detach previous marker handle before re-rendering.
if (crsiSignalsMarkersRef.current) {
try {
crsiSignalsMarkersRef.current.detach();
} catch (_) {
// ignore — series may have been removed
}
crsiSignalsMarkersRef.current = null;
}
const signals = (props.indicatorsByPane?.subpane_1 || []).find(
(i) => i.name === 'crsi_signals',
);
const raw = signals?.markers || [];
if (!raw.length) return;
const lwc = raw.map((m) => {
const isLong = m.kind.endsWith('_long');
const isBoth = m.kind.startsWith('both');
return {
time: toUnixSec(m.ts),
position: isLong ? 'belowBar' : 'aboveBar',
shape: isLong ? 'arrowUp' : 'arrowDown',
// White for BOTH (visible on both dark/bright), maroon for SLOW-only signals.
color: isBoth ? '#ffffff' : '#8b0000',
text: isBoth ? 'BOTH' : 'SLOW',
};
});
crsiSignalsMarkersRef.current = window.LightweightCharts.createSeriesMarkers(
crsiSlowSeries,
lwc,
);
}, [props.indicatorsByPane]);
// Apply scale mode (linear/log) to pane 0 only (cRSI oscillator pane should stay linear).
React.useEffect(() => {
const chart = mainChartRef.current;
if (!chart) return;
const panes = chart.panes();
// LWC v5: prefer per-pane priceScale; fall back to chart-level if API not available.
if (panes[0] && typeof panes[0].priceScale === 'function') {
panes[0].priceScale('right').applyOptions({ mode: scaleMode });
} else {
// Fallback: applies to all panes — acceptable until LWC v5 per-pane API is confirmed.
// TODO(follow-up): confirm panes()[i].priceScale availability on runtime LWC version.
chart.priceScale('right').applyOptions({ mode: scaleMode });
}
}, [scaleMode]);
// Toggle volume histogram pane and refresh data when priceRows change while volume is on.
React.useEffect(() => {
const chart = mainChartRef.current;
if (!chart) return;
if (showVolume) {
if (!volumeSeriesRef.current) {
// addPane() with no args (v5 API takes optional PaneOptions, not an integer index).
if (chart.panes().length < 3) chart.addPane();
volumeSeriesRef.current = chart.addSeries(
window.LightweightCharts.HistogramSeries,
{
color: theme.dim,
priceFormat: { type: 'volume' },
priceLineVisible: false,
lastValueVisible: false,
},
2,
);
const panes = chart.panes();
if (panes[2]) panes[2].setStretchFactor(20);
}
// Push/refresh data whether just created or pre-existing (handles priceRows changes).
volumeSeriesRef.current.setData(
(props.priceRows || []).map((r) => ({
time: toUnixSec(r.ts),
value: toNum(r.volume) || 0,
color:
toNum(r.close) >= toNum(r.open)
? `${theme.green}66`
: `${theme.red}66`,
}))
);
} else if (volumeSeriesRef.current) {
try { chart.removeSeries(volumeSeriesRef.current); } catch (e) { console.warn('volume: removeSeries failed:', e); }
volumeSeriesRef.current = null;
}
}, [showVolume, props.priceRows, theme.dim, theme.green, theme.red]);
// Toggle series visibility (supertrend, cRSI fast/slow).
React.useEffect(() => {
supertrendSeriesRef.current?.applyOptions({ visible: seriesVisibility.supertrend });
crsiFastSeriesRef.current?.applyOptions({ visible: seriesVisibility.crsi_fast });
crsiSlowSeriesRef.current?.applyOptions({ visible: seriesVisibility.crsi_slow });
}, [
seriesVisibility.supertrend,
seriesVisibility.crsi_fast,
seriesVisibility.crsi_slow,
]);
// Bind trade markers: full-height dashed vertical lines via LWC v5 ISeriesPrimitive API,
// plus arrow markers on the price series via v5 createSeriesMarkers.
React.useEffect(() => {
const priceSeries = priceSeriesRef.current?.series;
if (!mainChartRef.current || !priceSeries) return;
// --- Detach previous vertical primitives ---
for (const { targetSeries, primitive } of verticalPrimitivesRef.current) {
try {
targetSeries.detachPrimitive(primitive);
} catch (e) {
console.warn('trade markers: detachPrimitive failed:', e);
}
}
verticalPrimitivesRef.current = [];
// --- Detach previous arrow markers ---
if (priceMarkersHandleRef.current) {
try { priceMarkersHandleRef.current.detach(); }
catch (e) { console.warn('trade markers: detach failed:', e); }
priceMarkersHandleRef.current = null;
}
const trades = props.trades || [];
if (!trades.length) return;
const events = trades.flatMap(expandTradeEvents);
// --- Attach vertical primitives (one per trade event per pane) ---
// One representative series per pane is sufficient — the primitive draws on that pane's canvas.
const verticalTargets = [
{ series: priceSeries, opacity: 1 },
{ series: crsiFastSeriesRef.current, opacity: 0.6 },
].filter((t) => t.series != null);
for (const ev of events) {
const baseColor = ev.color === 'green' ? theme.green : theme.red;
for (const { series, opacity } of verticalTargets) {
// Append hex alpha suffix for attenuated panes (60% opacity ≈ 0x99).
const color = opacity < 1 ? `${baseColor}99` : baseColor;
const primitive = new VerticalLinePrimitive({
time: ev.time,
color,
lineWidth: 1,
dashed: true,
});
try {
series.attachPrimitive(primitive);
verticalPrimitivesRef.current.push({ targetSeries: series, primitive });
} catch (e) {
console.warn('trade markers: attachPrimitive failed:', e);
}
}
}
// --- Arrow markers on price series (unchanged) ---
const arrows = events.map((ev) => ({
time: ev.time,
position:
ev.type === 'entry'
? ev.isLong ? 'belowBar' : 'aboveBar'
: ev.isLong ? 'aboveBar' : 'belowBar',
color: ev.color === 'green' ? theme.green : theme.red,
shape:
ev.type === 'entry'
? ev.isLong ? 'arrowUp' : 'arrowDown'
: ev.isLong ? 'arrowDown' : 'arrowUp',
text: ev.label,
}));
priceMarkersHandleRef.current = window.LightweightCharts.createSeriesMarkers(priceSeries, arrows);
}, [props.trades, theme.green, theme.red, priceSeriesVersion]);
return (
setOpenOptions((v) => !v)}
onFit={() => mainChartRef.current?.timeScale().fitContent()}
onReset={() => {
mainChartRef.current?.timeScale().resetTimeScale();
mainChartRef.current?.timeScale().fitContent();
}}
/>
{openOptions && (
TYPE:{' '}
{[CHART_TYPE.CANDLE, CHART_TYPE.LINE, CHART_TYPE.HEIKIN].map((t) => (
))}
SCALE:{' '}
{[{ id: SCALE_MODE.LINEAR, label: 'LIN' }, { id: SCALE_MODE.LOG, label: 'LOG' }].map((o) => (
))}
VOLUME:{' '}
SHOW:{' '}
{[
{ id: 'supertrend', label: 'ST' },
{ id: 'crsi_fast', label: 'cRSI F' },
{ id: 'crsi_slow', label: 'cRSI S' },
].map((o) => (
))}
)}
{props.fetchStatus !== FETCH_STATUS.IDLE && (
)}
{props.fetchStatus === FETCH_STATUS.SLOW && (
{props.fetchContext?.mode === FETCH_MODE.RUN
? `RUNNING BACKTEST · ${props.fetchContext?.strategy || ''}`
: `LOADING BARS · ${props.fetchContext?.symbol || ''} / ${props.fetchContext?.timeframe || ''}${props.fetchContext?.rangeLabel ? ` · ${props.fetchContext.rangeLabel}` : ''}`}
)}
);
};
window.TradingChart = TradingChart;
if (!document.getElementById('tc-keyframes')) {
const style = document.createElement('style');
style.id = 'tc-keyframes';
style.textContent = `
@keyframes tc-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
@keyframes tc-spin {
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}