PageSourceSearch

https://degenai.dev/js/chart/backtest-live-condition-colors.spec.ancchd9udt.js

js degenai.dev collected 2026-10-06 14:16:49 UTC 10,834 bytes, 250 lines download raw bytes

1/**
2 * backtest-live.js condition-color diff-cache tests.
3 *
4 * INCIDENT: release v1.2.14.182 (commit 2bc2501f7) fixed a browser freeze in the admin
5 * backtest dialog. applyConditionColors looped over every loaded candle calling
6 * candleSeries.update() one bar at a time, and the dialog invoked it through
7 * setBacktestSignalMarkers on every 2-second poll tick with the full signal array. On a
8 * 30-day 1-minute run (~43k candles) that fired ~43k individual series mutations every
9 * 2 seconds and saturated the browser rendering pipeline.
10 *
11 * That commit's branch was never merged to master, so its batch-setData rewrite and its
12 * appendBacktestTradeMarkers / appendBacktestSignalMarkers incremental functions are gone.
13 * The property is instead held by the diff-only color cache _appliedColorState, added
14 * independently in commit 23baddf03: the loop still calls per-bar update(), but skips any
15 * candle whose computed color state matches the cached one, so a tick costs the delta
16 * rather than the whole series.
17 *
18 * Nothing asserted that skip, which meant a refactor of the recolor loop could reintroduce
19 * the freeze silently. These tests pin it. The contract:
20 *   - the first pass paints each non-warmup candle exactly once
21 *   - a repeat pass over an unchanged signal set paints nothing
22 *   - a pass that adds one signal paints one bar, not the whole series
23 *   - warmup candles are never recolored, on any pass
24 *   - turning the highlight flag off repaints the met bars back to default
25 *   - either call that replaces the series data drops the cache, so the next pass
26 *     repaints from scratch
27 *
28 * That last one is the mirror of the other five. The cache is only safe to consult while
29 * it describes what is actually on the series: setData wipes the colors off, so a cache
30 * that survived it would skip every candle and leave the chart uncolored while claiming
31 * otherwise. Both callers that replace the series, setAllCandles and setSimStartMarker,
32 * clear it for that reason, and both are pinned here.
33 *
34 * The rebuild-from-scratch path and its MAX_APPEND_REBUILDS bound are covered in
35 * backtest-live-selfheal.spec.js and deliberately not duplicated here.
36 */
37
38import { describe, it, expect, beforeEach, vi } from 'vitest';
39
40vi.mock('./chart-instance.js', () => ({
41    getChartInstance: vi.fn(),
42    getCandleSeries: vi.fn(),
43}));
44
45vi.mock('./markers-plugin.js', () => ({
46    createMarkersPlugin: vi.fn(),
47    updateMarkers: vi.fn(),
48    clearMarkers: vi.fn(),
49}));
50
51vi.mock('./backtest-timeline.js', () => ({
52    setSuppressChartSync: vi.fn(),
53    notifyChartDataChanged: vi.fn(),
54}));
55
56// Must match the module's own constants (backtest-live.js, top of file).
57const DEFAULT_UP_COLOR = '#84B03C';
58const CONDITION_UP_COLOR = '#B8F84E';
59
60// close >= open, so every fixture candle is an "up" bar and the expected colors above
61// are the up-variants.
62function candle(time, extra = {}) {
63    return { time, open: 1, high: 2, low: 0.5, close: 1.5, ...extra };
64}
65
66// Recolor passes go through update(bar, true) -- the historicalUpdate flag. Append
67// writes use the one-argument form, so this is how a repaint is told from an append.
68function recoloredBars(series) {
69    return series.update.mock.calls.filter(c => c[1] === true).map(c => c[0]);
70}
71
72function recoloredTimes(series) {
73    return recoloredBars(series).map(b => b.time);
74}
75
76describe('backtest-live condition-color diff cache', () => {
77    let bt;
78    let chartInstance;
79    let series;
80    let timeScale;
81    let chart;
82
83    beforeEach(async () => {
84        vi.resetModules();
85        chartInstance = await import('./chart-instance.js');
86        bt = await import('./backtest-live.js');
87
88        timeScale = {
89            scrollToPosition: vi.fn(),
90            scrollPosition: vi.fn(() => 0),
91            getVisibleRange: vi.fn(() => ({ from: 100, to: 200 })),
92            setVisibleRange: vi.fn(),
93            subscribeVisibleLogicalRangeChange: vi.fn(),
94            unsubscribeVisibleLogicalRangeChange: vi.fn(),
95        };
96        chart = {
97            timeScale: vi.fn(() => timeScale),
98            chartElement: vi.fn(() => null),
99            subscribeCrosshairMove: vi.fn(),
100            unsubscribeCrosshairMove: vi.fn(),
101            subscribeClick: vi.fn(),
102            unsubscribeClick: vi.fn(),
103        };
104        series = {
105            setData: vi.fn(),
106            update: vi.fn(),
107            attachPrimitive: vi.fn(),
108            detachPrimitive: vi.fn(),
109        };
110        chartInstance.getChartInstance.mockReturnValue(chart);
111        chartInstance.getCandleSeries.mockReturnValue(series);
112
113        // 60-second interval: signal times must sit on 60-second boundaries to align
114        // to candle open-times (alignToCandle floors to the interval bucket).
115        bt.initBacktestLive(60);
116    });
117
118    it('paints a condition-met candle once on the first pass', () => {
119        bt.appendCandles([candle(60), candle(120), candle(180)]);
120        expect(series.update).not.toHaveBeenCalled(); // bulk load went through setData
121
122        bt.setBacktestSignalMarkers([{ time: 120, conditionMet: true }], true);
123
124        // From-scratch pass: every candle painted, each exactly once
125        expect(recoloredTimes(series).sort((a, b) => a - b)).toEqual([60, 120, 180]);
126
127        const byTime = new Map(recoloredBars(series).map(b => [b.time, b]));
128        expect(byTime.get(120).color).toBe(CONDITION_UP_COLOR);
129        expect(byTime.get(60).color).toBe(DEFAULT_UP_COLOR);
130        expect(byTime.get(180).color).toBe(DEFAULT_UP_COLOR);
131    });
132
133    it('repeating the same signal set does not repaint anything', () => {
134        bt.appendCandles([candle(60), candle(120), candle(180)]);
135        const signals = [{ time: 120, conditionMet: true }];
136        bt.setBacktestSignalMarkers(signals, true);
137        expect(recoloredBars(series)).toHaveLength(3);
138
139        series.update.mockClear();
140
141        // The poll loop re-sends the full signal array every tick. Nothing changed, so
142        // nothing may be repainted. This is the assertion that fails if the diff cache
143        // is removed: without it every candle is rewritten on every tick.
144        bt.setBacktestSignalMarkers(signals, true);
145        expect(series.update).not.toHaveBeenCalled();
146
147        // Still true on a third identical tick
148        bt.setBacktestSignalMarkers(signals, true);
149        expect(series.update).not.toHaveBeenCalled();
150    });
151
152    it('a tick that adds one signal repaints one bar, not the whole series', () => {
153        // Large enough that an O(series) repaint is unmistakable against an O(delta) one.
154        const BAR_COUNT = 2000;
155        const candles = [];
156        for (let i = 1; i <= BAR_COUNT; i++) candles.push(candle(i * 60));
157        bt.appendCandles(candles);
158
159        const signals = [{ time: 60, conditionMet: true }];
160        bt.setBacktestSignalMarkers(signals, true);
161        expect(recoloredBars(series)).toHaveLength(BAR_COUNT); // from-scratch pass
162
163        series.update.mockClear();
164
165        // Next poll tick: the full array again, plus one newly condition-met candle.
166        const newSignalTime = 900 * 60;
167        bt.setBacktestSignalMarkers([...signals, { time: newSignalTime, conditionMet: true }], true);
168
169        const repainted = recoloredBars(series);
170        expect(repainted).toHaveLength(1);
171        expect(repainted[0].time).toBe(newSignalTime);
172        expect(repainted[0].color).toBe(CONDITION_UP_COLOR);
173    });
174
175    it('never recolors warmup candles', () => {
176        bt.appendCandles([
177            candle(60, { isWarmup: true }),
178            candle(120, { isWarmup: true }),
179            candle(180),
180            candle(240),
181        ]);
182
183        // A signal landing on a warmup bar must not overwrite its muted tint, and the
184        // warmup bars must not be rewritten on any later tick either (they are skipped
185        // before the cache, so they never enter it).
186        const signals = [
187            { time: 60, conditionMet: true },
188            { time: 180, conditionMet: true },
189        ];
190        bt.setBacktestSignalMarkers(signals, true);
191
192        expect(recoloredTimes(series).sort((a, b) => a - b)).toEqual([180, 240]);
193
194        series.update.mockClear();
195        bt.setBacktestSignalMarkers(signals, true);
196        expect(series.update).not.toHaveBeenCalled();
197    });
198
199    it('turning the highlight flag off repaints the met bar back to default', () => {
200        bt.appendCandles([candle(60), candle(120), candle(180)]);
201        const signals = [{ time: 120, conditionMet: true }];
202        bt.setBacktestSignalMarkers(signals, true);
203        expect(recoloredBars(series)).toHaveLength(3);
204
205        series.update.mockClear();
206
207        // The dialog's "show all signals" checkbox re-sends the same signals with
208        // highlight off (BacktestLiveChart.razor, OnShowAllSignalsChanged). The cache
209        // must treat that as a color change on the met bar and only on the met bar:
210        // short-circuiting the loop when highlight is off would leave it lime forever.
211        bt.setBacktestSignalMarkers(signals, false);
212
213        const off = recoloredBars(series);
214        expect(off).toHaveLength(1);
215        expect(off[0].time).toBe(120);
216        expect(off[0].color).toBe(DEFAULT_UP_COLOR);
217
218        series.update.mockClear();
219
220        // And back on again, still one bar.
221        bt.setBacktestSignalMarkers(signals, true);
222        const on = recoloredBars(series);
223        expect(on).toHaveLength(1);
224        expect(on[0].time).toBe(120);
225        expect(on[0].color).toBe(CONDITION_UP_COLOR);
226    });
227
228    // Both of these write the untinted candles back over the series with setData, so
229    // every condition color is gone afterwards. The cache has to go with them: one that
230    // survived would skip all three bars and leave them default-colored on screen while
231    // reporting them as already painted. Table-driven so a third setData caller cannot
232    // be added without a row here. simStart is the first candle's own time, which leaves
233    // nothing before the boundary and so tints no candle as warmup.
234    it.each([
235        ['setAllCandles', (mod, candles) => mod.setAllCandles(candles)],
236        ['setSimStartMarker', (mod, candles) => mod.setSimStartMarker(candles[0].time, 60)],
237    ])('repaints from scratch after %s replaces the series data', (_name, replaceSeriesData) => {
238        const candles = [candle(60), candle(120), candle(180)];
239        bt.appendCandles(candles);
240        const signals = [{ time: 120, conditionMet: true }];
241        bt.setBacktestSignalMarkers(signals, true);
242        expect(recoloredBars(series)).toHaveLength(3);
243
244        replaceSeriesData(bt, candles);
245        series.update.mockClear();
246
247        bt.setBacktestSignalMarkers(signals, true);
248        expect(recoloredTimes(series).sort((a, b) => a - b)).toEqual([60, 120, 180]);
249    });
250});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.