'use client'; import { useEffect } from 'react'; import _ from 'lodash'; import { registerOverlay, registerIndicator, Chart } from 'klinecharts'; import { KlineChartItem, OrderLogData } from '@tradejs/types'; import { useBacktest as useBacktestStore } from '#store'; import { TradeZoneMode, createTradeZonePointFigure, } from '../figures/tradeZonePointFigure'; import { createBacktestMarkersPointFigure } from '../figures/backtestMarkersPointFigure'; import { MarkerMeta, MarkerShape } from '../figures/backtestFigureTypes'; import { collectSignalFiguresFromOrderLog, drawSignalFigures, ensureBaseFigureOverlaysRegistered, removeSignalFigures, } from '@tradejs/core/figures'; import '../figures'; const green = '#84cc16'; const red = '#dc2626'; const darkRed = '#7f1d1d'; const darkGreen = '#365314'; const orange = '#fb923c'; const grayTransparent = 'rgba(156,163,175,0.45)'; const greenTransparent = 'rgba(132,204,22,0.45)'; const redTransparent = 'rgba(220,38,38,0.45)'; type ChartPoint = { timestamp: number; value: number }; interface AlignedOrderEvent { event: OrderLogData[number]; alignedTimestamp: number; } interface TradeZone { id: string; start: ChartPoint; tpEnd: ChartPoint; slEnd: ChartPoint; } const resolveShapeAndColor = ( eventType: string, ): { shape: MarkerShape; color: string; } => { switch (eventType) { case 'OPEN_LONG': return { shape: 'RECT', color: green }; case 'TAKE_PROFIT_LONG': return { shape: 'STAR', color: red }; case 'CLOSE_LONG': return { shape: 'DIAMOND', color: darkRed }; case 'STOP_LOSS_LONG': return { shape: 'CIRCLE', color: darkRed }; case 'OPEN_SHORT': return { shape: 'RECT', color: red }; case 'TAKE_PROFIT_SHORT': return { shape: 'STAR', color: green }; case 'CLOSE_SHORT': return { shape: 'DIAMOND', color: darkRed }; case 'STOP_LOSS_SHORT': return { shape: 'CIRCLE', color: darkGreen }; default: return { shape: 'CIRCLE', color: '#ffffff' }; } }; const walkCandlesAndEvents = ( candles: KlineChartItem[], rawEvents: OrderLogData, ): { markersFlat: MarkerMeta[]; markersByTs: Record; profitByIndex: Array; alignedEvents: AlignedOrderEvent[]; } => { const events = [...rawEvents].sort((a, b) => a.timestamp - b.timestamp); const markersFlat: MarkerMeta[] = []; const markersByTs: Record = {}; const profitByIndex: Array = new Array( candles.length, ).fill(undefined); const alignedEvents: AlignedOrderEvent[] = []; let eventCursor = 0; let currentAmount: number | undefined = undefined; for (let candleIndex = 0; candleIndex < candles.length; candleIndex++) { const candle = candles[candleIndex]; const currTs = candle.timestamp; const prevTs = candleIndex > 0 ? candles[candleIndex - 1].timestamp : -Infinity; for (; eventCursor < events.length; eventCursor++) { const evt = events[eventCursor]; if (evt.timestamp > currTs) { break; } if (evt.timestamp > prevTs && evt.timestamp <= currTs) { const { shape, color } = resolveShapeAndColor(evt.type); const marker: MarkerMeta = { shape, color, timestamp: currTs, value: evt.price, type: evt.type, profit: evt.profit, amount: evt.amount, tradeIndex: evt.index, }; markersFlat.push(marker); if (!markersByTs[currTs]) { markersByTs[currTs] = []; } markersByTs[currTs].push(marker); alignedEvents.push({ event: evt, alignedTimestamp: currTs }); currentAmount = evt.amount; continue; } if (evt.timestamp <= prevTs) { currentAmount = evt.amount; continue; } } profitByIndex[candleIndex] = currentAmount; } return { markersFlat, markersByTs, profitByIndex, alignedEvents }; }; const groupMarkersForOverlay = ( markers: MarkerMeta[], ): { points: Array<{ timestamp: number; value: number }>; groupedExtendData: MarkerMeta[][]; } => { const byKey: Record< string, { timestamp: number; value: number; items: MarkerMeta[] } > = {}; for (const marker of markers) { const key = `${marker.timestamp}__${marker.value}`; if (!byKey[key]) { byKey[key] = { timestamp: marker.timestamp, value: marker.value, items: [], }; } byKey[key].items.push(marker); } const points: Array<{ timestamp: number; value: number }> = []; const groupedExtendData: MarkerMeta[][] = []; for (const { timestamp, value, items } of Object.values(byKey)) { points.push({ timestamp, value }); groupedExtendData.push(items); } return { points, groupedExtendData }; }; const buildIndicatorData = ( candles: KlineChartItem[], markersByTs: Record, profitByIndex: Array, ): Record< number, { profit?: number; startAmount?: number; endAmount?: number; maxAmount?: number; minAmount?: number; markers: MarkerMeta[]; } > => { const result: Record< number, { profit?: number; startAmount?: number; endAmount?: number; maxAmount?: number; minAmount?: number; markers: MarkerMeta[]; } > = {}; const amounts = profitByIndex.filter((value): value is number => Number.isFinite(value), ); const startAmount = amounts[0]; const endAmount = amounts.length > 0 ? amounts[amounts.length - 1] : undefined; const maxAmount = amounts.length > 0 ? Math.max(...amounts) : undefined; const minAmount = amounts.length > 0 ? Math.min(...amounts) : undefined; for (let i = 0; i < candles.length; i++) { const ts = candles[i].timestamp; result[ts] = { profit: profitByIndex[i], startAmount, endAmount, maxAmount, minAmount, markers: markersByTs[ts] ?? [], }; } return result; }; let backtestMarkersRegistered = false; let backtestTradeZonesRegistered = false; const ensureBacktestMarkersRegistered = () => { if (backtestMarkersRegistered) return; registerOverlay({ name: 'backtestMarkers', totalStep: 1, createPointFigures: createBacktestMarkersPointFigure, }); backtestMarkersRegistered = true; }; const ensureBacktestTradeZonesRegistered = () => { if (backtestTradeZonesRegistered) return; registerOverlay({ name: 'BacktestTradeZone', totalStep: 2, needDefaultPointFigure: false, needDefaultXAxisFigure: false, needDefaultYAxisFigure: false, createPointFigures: createTradeZonePointFigure, }); backtestTradeZonesRegistered = true; }; const buildBacktestTradeZones = ( alignedEvents: AlignedOrderEvent[], ): TradeZone[] => { const trades = new Map< string, { open?: AlignedOrderEvent; lastClose?: AlignedOrderEvent } >(); for (const aligned of alignedEvents) { const signalId = aligned.event.signal?.signalId; if (!signalId) continue; const current = trades.get(signalId) ?? {}; const isOpen = aligned.event.type.startsWith('OPEN_'); if (isOpen) { current.open = aligned; } else { current.lastClose = aligned; } trades.set(signalId, current); } const zones: TradeZone[] = []; for (const [signalId, trade] of trades.entries()) { const open = trade.open; const close = trade.lastClose; if (!open || !close) continue; const prices = open.event.signal?.prices; if (!prices) continue; zones.push({ id: signalId, start: { timestamp: open.alignedTimestamp, value: open.event.price, }, tpEnd: { timestamp: close.alignedTimestamp, value: prices.takeProfitPrice, }, slEnd: { timestamp: close.alignedTimestamp, value: prices.stopLossPrice, }, }); } return zones; }; const createBacktestProfit = ( chart: Chart, latestByTs: Record< number, { profit?: number; startAmount?: number; endAmount?: number; maxAmount?: number; minAmount?: number; markers: MarkerMeta[]; } > = {}, ) => { registerIndicator({ name: 'BacktestProfit', shortName: 'Backtest', series: 'price', figures: [ { key: 'profit', title: 'Profit', type: 'line', }, { key: 'startAmount', title: 'Start: ', type: 'line', styles: () => ({ color: grayTransparent, size: 1, style: 'dashed', dashedValue: [4, 4], }) as any, }, { key: 'endAmount', title: 'End: ', type: 'line', styles: () => ({ color: grayTransparent, size: 1, style: 'dashed', dashedValue: [4, 4], }) as any, }, { key: 'maxAmount', title: 'Max: ', type: 'line', styles: () => ({ color: greenTransparent, size: 1, style: 'dashed', dashedValue: [4, 4], }) as any, }, { key: 'minAmount', title: 'Min: ', type: 'line', styles: () => ({ color: redTransparent, size: 1, style: 'dashed', dashedValue: [4, 4], }) as any, }, ], calc: () => latestByTs, createTooltipDataSource: ({ indicator, crosshair }) => { const result = indicator.result as typeof latestByTs; const ts = crosshair.kLineData?.timestamp; const bucket = ts ? result[ts] : undefined; const legends: Array<{ title: string; value: { text: string; color: string }; }> = []; if (bucket && bucket.profit !== undefined) { legends.push({ title: 'amount: ', value: { text: bucket.profit.toFixed(2), color: orange, }, }); } if (bucket && bucket.markers.length > 0) { for (const meta of bucket.markers) { legends.push({ title: `${meta.tradeIndex}:type: `, value: { text: meta.type, color: 'white' }, }); legends.push({ title: `${meta.tradeIndex}:profit: `, value: { text: meta.profit.toFixed(2), color: meta.profit >= 0 ? green : red, }, }); } } return { name: 'Backtest', calcParamsText: '', features: [], legends, }; }, }); chart.createIndicator('BacktestProfit', false); }; export const useBacktest = (chart: Chart | null, id: string | undefined) => { const { backtest } = useBacktestStore(id); const enabled = Boolean(id); const candlesLength = chart?.getDataList()?.length || 0; useEffect(() => { if (!chart || !enabled || _.isEmpty(backtest)) { return; } const candles = chart.getDataList() as KlineChartItem[]; if (!candles || candles.length === 0) { return; } const { markersFlat, markersByTs, profitByIndex, alignedEvents } = walkCandlesAndEvents(candles, backtest); const { points, groupedExtendData } = groupMarkersForOverlay(markersFlat); const tradeZones = buildBacktestTradeZones(alignedEvents); const signalFigures = collectSignalFiguresFromOrderLog(backtest); const signalFigureOverlays: ReturnType = []; const tradeZoneOverlayIds: string[] = []; ensureBacktestMarkersRegistered(); if (points.length > 0) { chart.createOverlay({ name: 'backtestMarkers', points, extendData: groupedExtendData, }); } if (tradeZones.length > 0) { ensureBacktestTradeZonesRegistered(); for (const zone of tradeZones) { const tpId = `backtest-trade-zone-${zone.id}-tp`; const slId = `backtest-trade-zone-${zone.id}-sl`; tradeZoneOverlayIds.push(tpId, slId); chart.createOverlay({ name: 'BacktestTradeZone', id: tpId, points: [zone.start, zone.tpEnd], zLevel: 2, extendData: { mode: 'TP' satisfies TradeZoneMode }, }); chart.createOverlay({ name: 'BacktestTradeZone', id: slId, points: [zone.start, zone.slEnd], zLevel: 2, extendData: { mode: 'SL' satisfies TradeZoneMode }, }); } } if (signalFigures.length > 0) { ensureBaseFigureOverlaysRegistered(); for (const item of signalFigures) { const idPrefix = `backtest-entry-model-${item.signalId ?? `idx-${item.index}`}`; signalFigureOverlays.push( ...drawSignalFigures({ chart, idPrefix, figures: item.figures, }), ); } } const latestByTs = buildIndicatorData(candles, markersByTs, profitByIndex); createBacktestProfit(chart, latestByTs); return () => { chart.removeOverlay({ name: 'backtestMarkers' }); chart.removeIndicator({ name: 'BacktestProfit' }); if (signalFigureOverlays.length > 0) { removeSignalFigures(chart, signalFigureOverlays); } if (tradeZoneOverlayIds.length > 0) { for (const overlayId of tradeZoneOverlayIds) { chart.removeOverlay({ name: 'BacktestTradeZone', id: overlayId }); } } }; }, [chart, enabled, backtest, id, candlesLength]); return null; };