@tradejs/cli 3.1.29-beta.289 → 3.1.29-beta.291

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,955 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/scripts/researchVisuals.ts
31
+ var researchVisuals_exports = {};
32
+ __export(researchVisuals_exports, {
33
+ main: () => main
34
+ });
35
+ module.exports = __toCommonJS(researchVisuals_exports);
36
+
37
+ // src/lib/researchVisuals.ts
38
+ var import_promises = __toESM(require("fs/promises"));
39
+ var import_node_path = __toESM(require("path"));
40
+ var import_node_zlib = require("zlib");
41
+ var import_node_crypto2 = require("crypto");
42
+ var import_sharp = __toESM(require("sharp"));
43
+
44
+ // src/lib/researchVisuals/model.ts
45
+ var object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
46
+ var finite = (v) => typeof v === "number" && Number.isFinite(v);
47
+ var number = (v, label) => {
48
+ if (!finite(v)) throw new Error(`Invalid ${label}`);
49
+ return v;
50
+ };
51
+ var text = (v, label) => {
52
+ if (typeof v !== "string" || !v.trim()) throw new Error(`Missing ${label}`);
53
+ return v;
54
+ };
55
+ var time = (v, label) => {
56
+ const n = number(v, label);
57
+ if (!Number.isSafeInteger(n) || n < 0 || n > 864e13)
58
+ throw new Error(`Invalid ${label}`);
59
+ return n;
60
+ };
61
+ var nullable = (v) => v == null ? null : number(v, "price");
62
+ var point = (v) => {
63
+ const p = object(v);
64
+ return {
65
+ timestamp: time(p.timestamp, "point timestamp"),
66
+ value: number(p.value, "point value")
67
+ };
68
+ };
69
+ function normalizeFigures(v) {
70
+ const f = object(v);
71
+ const groups = (key) => (Array.isArray(f[key]) ? f[key] : []).map((g) => {
72
+ const a = object(g);
73
+ return { points: (Array.isArray(a.points) ? a.points : []).map(point) };
74
+ });
75
+ return {
76
+ lines: groups("lines"),
77
+ points: groups("points"),
78
+ zones: (Array.isArray(f.zones) ? f.zones : []).map((z) => ({
79
+ start: point(object(z).start),
80
+ end: point(object(z).end)
81
+ }))
82
+ };
83
+ }
84
+ function normalizeCandles(v) {
85
+ if (v === void 0) return [];
86
+ if (!Array.isArray(v)) throw new Error("Candles must be an array");
87
+ const out = v.map((raw) => {
88
+ const b = object(raw);
89
+ const c = {
90
+ timestamp: time(b.timestamp, "candle timestamp"),
91
+ open: number(b.open, "open"),
92
+ high: number(b.high, "high"),
93
+ low: number(b.low, "low"),
94
+ close: number(b.close, "close"),
95
+ volume: number(b.volume, "volume")
96
+ };
97
+ if (c.low > Math.min(c.open, c.close) || c.high < Math.max(c.open, c.close) || c.low > c.high || c.low <= 0 || c.volume < 0)
98
+ throw new Error("Invalid OHLCV");
99
+ for (const key of ["smaFast", "smaSlow", "bbUpper", "bbLower"])
100
+ if (b[key] != null) c[key] = number(b[key], key);
101
+ return c;
102
+ }).sort((a, b) => a.timestamp - b.timestamp);
103
+ if (out.some((b, i) => i > 0 && b.timestamp === out[i - 1].timestamp))
104
+ throw new Error("Duplicate candle timestamp");
105
+ return out;
106
+ }
107
+ function normalizeTrade(raw, fallbackStrategy, candlesBySymbol = {}) {
108
+ const r = object(raw), payload = object(r.payload), s = object(payload.signal), t = object(r.trade ?? r.tradeResult), prices = object(r.prices ?? s.prices), features = {}, pockets = {}, groups = {};
109
+ for (const [key, value] of Object.entries(object(r.features)))
110
+ if (finite(value)) features[key] = value;
111
+ else throw new Error(`Invalid feature ${key}`);
112
+ const snapshot = object(payload.featureSnapshot), values = object(snapshot.values);
113
+ for (const def of Array.isArray(snapshot.definitions) ? snapshot.definitions : []) {
114
+ const d = object(def);
115
+ const value = typeof d.id === "string" ? values[d.id] : void 0;
116
+ if (d.role === "input" && typeof d.id === "string" && finite(value))
117
+ features[d.id] = value;
118
+ }
119
+ for (const [key, value] of Object.entries(object(r.pockets))) {
120
+ if (typeof value !== "boolean") throw new Error(`Invalid pocket ${key}`);
121
+ pockets[key] = value;
122
+ }
123
+ if (typeof r.approved === "boolean") pockets.nativeGate = r.approved;
124
+ for (const [key, value] of Object.entries(object(r.groups))) {
125
+ if (typeof value !== "string") throw new Error(`Invalid group ${key}`);
126
+ groups[key] = value;
127
+ }
128
+ const additional = object(payload.additionalIndicators), profile = object(
129
+ additional.tickerProfile ?? object(additional.baseContext).tickerProfile
130
+ );
131
+ for (const key of [
132
+ "moneyGroup",
133
+ "volatilityGroup",
134
+ "btcCorrelationGroup",
135
+ "ethCorrelationGroup"
136
+ ])
137
+ if (typeof profile[key] === "string") groups[key] = profile[key];
138
+ const direction = r.direction ?? s.direction;
139
+ if (direction !== "LONG" && direction !== "SHORT")
140
+ throw new Error("Invalid trade direction");
141
+ const symbol = text(r.symbol ?? s.symbol, "symbol");
142
+ if (!symbol.endsWith("USDT"))
143
+ throw new Error(`Only USDT contracts: ${symbol}`);
144
+ const strategy = text(
145
+ r.strategy ?? r.strategyName ?? s.strategy ?? fallbackStrategy,
146
+ "strategy"
147
+ );
148
+ const entryTime = time(r.entryTime ?? t.entryTimestamp, "entryTime"), exitTime = time(r.exitTime ?? t.exitTimestamp, "exitTime"), signalTime = time(r.signalTime ?? r.timestamp ?? s.timestamp, "signalTime");
149
+ if (exitTime < entryTime || entryTime < signalTime)
150
+ throw new Error("Invalid trade chronology");
151
+ const entryPrice = number(r.entryPrice ?? t.entryPrice, "entryPrice"), exitPrice = number(r.exitPrice ?? t.exitPrice, "exitPrice");
152
+ if (entryPrice <= 0 || exitPrice <= 0)
153
+ throw new Error("Invalid execution price");
154
+ return {
155
+ id: text(
156
+ r.id ?? r.key ?? `${strategy}:${symbol}:${direction}:${text(r.signalId ?? s.signalId, "signalId")}:${entryTime}:${exitTime}`,
157
+ "trade id"
158
+ ),
159
+ strategy,
160
+ symbol,
161
+ direction,
162
+ entryTime,
163
+ exitTime,
164
+ signalTime,
165
+ entryPrice,
166
+ exitPrice,
167
+ pnl: number(r.pnl ?? t.netProfit, "net PnL"),
168
+ stop: nullable(r.stop ?? prices.stopLossPrice),
169
+ target: nullable(r.target ?? prices.takeProfitPrice),
170
+ pockets,
171
+ features,
172
+ groups,
173
+ candles: normalizeCandles(r.candles ?? candlesBySymbol[symbol]),
174
+ figures: normalizeFigures(r.figures ?? payload.figures)
175
+ };
176
+ }
177
+ function normalizeDataset(input, options = {}) {
178
+ const root = object(input);
179
+ const archived = root.schema === "trade-atlas-chart-points/v1";
180
+ if (!Array.isArray(input) && !archived && root.schema !== "tradejs-research-visuals/v1")
181
+ throw new Error("Unsupported research visual schema");
182
+ const population = archived || root.population === "selected" ? "selected" : options.population ?? root.population;
183
+ if (population !== "complete" && population !== "selected")
184
+ throw new Error("Declare population: complete or selected");
185
+ const intervalMs = options.intervalMs ?? (archived ? Number(root.timeframe) * 6e4 : root.intervalMs);
186
+ if (!finite(intervalMs) || !Number.isSafeInteger(intervalMs) || intervalMs <= 0)
187
+ throw new Error("Positive intervalMs is required");
188
+ const rows = Array.isArray(input) ? input : archived ? root.cards : root.trades;
189
+ if (!Array.isArray(rows)) throw new Error("Trades must be an array");
190
+ const runs = new Set(
191
+ rows.map((r) => object(r).backtestRunId).filter(Boolean)
192
+ );
193
+ if (runs.size > 1)
194
+ throw new Error("Mixed backtest runs; provide one immutable lineage");
195
+ const trades = rows.map(
196
+ (r) => normalizeTrade(
197
+ r,
198
+ options.strategy ?? String(root.strategy ?? ""),
199
+ options.candlesBySymbol ?? object(root.candlesBySymbol)
200
+ )
201
+ );
202
+ const ids = /* @__PURE__ */ new Set();
203
+ for (const t of trades) {
204
+ const key = `${t.strategy}:${t.id}`;
205
+ if (ids.has(key)) throw new Error(`Duplicate trade: ${key}`);
206
+ ids.add(key);
207
+ }
208
+ return {
209
+ schema: "tradejs-research-visuals/v1",
210
+ population,
211
+ source: options.source ?? String(root.source ?? root.runId ?? [...runs][0] ?? "caller-supplied"),
212
+ intervalMs,
213
+ trades
214
+ };
215
+ }
216
+
217
+ // src/lib/researchVisuals/analysis.ts
218
+ var import_node_crypto = require("crypto");
219
+ var summarize = (rows) => {
220
+ const wins = rows.filter((t) => t.pnl > 0), losses = rows.filter((t) => t.pnl < 0), grossProfit = wins.reduce((s, t) => s + t.pnl, 0), grossLoss = -losses.reduce((s, t) => s + t.pnl, 0);
221
+ return {
222
+ n: rows.length,
223
+ wins: wins.length,
224
+ losses: losses.length,
225
+ breakeven: rows.length - wins.length - losses.length,
226
+ pnl: grossProfit - grossLoss,
227
+ pf: grossLoss ? grossProfit / grossLoss : null,
228
+ mean: rows.length ? (grossProfit - grossLoss) / rows.length : null
229
+ };
230
+ };
231
+ var hash = (s) => (0, import_node_crypto.createHash)("sha256").update(s).digest("hex");
232
+ function selectAtlas(trades, count = 100, seed = "trade-atlas/v1") {
233
+ if (!Number.isSafeInteger(count) || count < 1 || count > 1e3)
234
+ throw new Error("count must be 1..1000");
235
+ const selected = [];
236
+ for (const strategy of [...new Set(trades.map((t) => t.strategy))].sort())
237
+ for (const sign of [1, -1]) {
238
+ const population = trades.filter((t) => t.strategy === strategy && Math.sign(t.pnl) === sign).sort(
239
+ (a, b) => a.signalTime - b.signalTime || a.id.localeCompare(b.id)
240
+ );
241
+ const n = Math.min(count, population.length);
242
+ for (let i = 0; i < n; i++)
243
+ selected.push(
244
+ population.slice(
245
+ Math.floor(i * population.length / n),
246
+ Math.floor((i + 1) * population.length / n)
247
+ ).sort(
248
+ (a, b) => hash(`${seed}:${a.id}`).localeCompare(
249
+ hash(`${seed}:${b.id}`)
250
+ ) || a.id.localeCompare(b.id)
251
+ )[0]
252
+ );
253
+ }
254
+ return selected;
255
+ }
256
+ var median = (a) => {
257
+ const s = [...a].sort((a2, b) => a2 - b);
258
+ return s.length ? (s[Math.floor((s.length - 1) / 2)] + s[Math.floor(s.length / 2)]) / 2 : null;
259
+ };
260
+ function analyzeVisualTrades(trades) {
261
+ const scopes = ["ALL", "LONG", "SHORT"];
262
+ return Object.fromEntries(
263
+ scopes.map((direction) => {
264
+ const rows = trades.filter(
265
+ (t) => direction === "ALL" || t.direction === direction
266
+ );
267
+ const pockets = Object.fromEntries(
268
+ [...new Set(rows.flatMap((t) => Object.keys(t.pockets)))].sort().map((p) => [
269
+ p,
270
+ {
271
+ pass: summarize(rows.filter((t) => t.pockets[p] === true)),
272
+ fail: summarize(rows.filter((t) => t.pockets[p] === false)),
273
+ unknown: rows.filter((t) => t.pockets[p] === void 0).length
274
+ }
275
+ ])
276
+ );
277
+ const contrasts = [
278
+ ...new Set(rows.flatMap((t) => Object.keys(t.features)))
279
+ ].sort().map((feature) => {
280
+ const known = rows.filter((t) => t.features[feature] !== void 0), wins = known.filter((t) => t.pnl > 0).map((t) => t.features[feature]), losses = known.filter((t) => t.pnl < 0).map((t) => t.features[feature]);
281
+ const values = known.map((t) => t.features[feature]).sort((a, b) => a - b);
282
+ const iqr = values.length ? values[Math.floor((values.length - 1) * 0.75)] - values[Math.floor((values.length - 1) * 0.25)] : 0;
283
+ const winMedian = median(wins), lossMedian = median(losses);
284
+ return {
285
+ feature,
286
+ n: known.length,
287
+ missing: rows.length - known.length,
288
+ wins: wins.length,
289
+ losses: losses.length,
290
+ winMedian,
291
+ lossMedian,
292
+ iqr,
293
+ medianDifferenceIqr: iqr && winMedian !== null && lossMedian !== null ? (winMedian - lossMedian) / iqr : null
294
+ };
295
+ });
296
+ return [direction, { summary: summarize(rows), pockets, contrasts }];
297
+ })
298
+ );
299
+ }
300
+ function drawdownEpisodes(dataset) {
301
+ if (dataset.population !== "complete")
302
+ throw new Error(
303
+ "Drawdowns require the complete population, never a selected atlas"
304
+ );
305
+ const events = /* @__PURE__ */ new Map();
306
+ for (const t of dataset.trades) {
307
+ const group = events.get(t.exitTime) ?? [];
308
+ group.push(t);
309
+ events.set(t.exitTime, group);
310
+ }
311
+ const output = [];
312
+ let equity = 0, peak = 0, peakTime = dataset.trades.length ? dataset.trades.reduce((min, t) => Math.min(min, t.entryTime), Infinity) : 0;
313
+ let episode;
314
+ let members = [];
315
+ const finish = () => {
316
+ if (!episode) return;
317
+ const groups = /* @__PURE__ */ new Map();
318
+ for (const t of members)
319
+ for (const key of [
320
+ `strategy:${t.strategy}`,
321
+ `symbol:${t.symbol}`,
322
+ `direction:${t.direction}`,
323
+ ...Object.entries(t.groups).map(([k, v]) => `group:${k}:${v}`)
324
+ ]) {
325
+ const a = groups.get(key) ?? [];
326
+ a.push(t);
327
+ groups.set(key, a);
328
+ }
329
+ episode.contributions = Object.fromEntries(
330
+ [...groups].map(([k, rows]) => [k, summarize(rows)])
331
+ );
332
+ output.push(episode);
333
+ episode = void 0;
334
+ members = [];
335
+ };
336
+ for (const [timestamp, rows] of [...events].sort((a, b) => a[0] - b[0])) {
337
+ rows.sort(
338
+ (a, b) => a.strategy.localeCompare(b.strategy) || a.id.localeCompare(b.id)
339
+ );
340
+ equity += rows.reduce((s, t) => s + t.pnl, 0);
341
+ if (!episode && equity < peak)
342
+ episode = {
343
+ id: `drawdown-${output.length + 1}`,
344
+ start: peakTime,
345
+ trough: timestamp,
346
+ end: timestamp,
347
+ recovered: false,
348
+ depth: 0,
349
+ peak,
350
+ points: [[peakTime, peak]],
351
+ tradeIds: [],
352
+ contributions: {}
353
+ };
354
+ if (episode) {
355
+ episode.points.push([timestamp, equity]);
356
+ episode.end = timestamp;
357
+ members.push(...rows);
358
+ episode.tradeIds.push(...rows.map((t) => `${t.strategy}:${t.id}`));
359
+ if (peak - equity > episode.depth) {
360
+ episode.depth = peak - equity;
361
+ episode.trough = timestamp;
362
+ }
363
+ if (equity >= peak) {
364
+ episode.recovered = true;
365
+ finish();
366
+ }
367
+ }
368
+ if (equity >= peak) {
369
+ peak = equity;
370
+ peakTime = timestamp;
371
+ }
372
+ }
373
+ finish();
374
+ return output;
375
+ }
376
+
377
+ // src/lib/strategyResearch/reportFormat.ts
378
+ var escapeResearchHtml = (value) => String(value ?? "").replace(
379
+ /[&<>"']/g,
380
+ (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]
381
+ );
382
+
383
+ // src/lib/strategyResearch/charts.ts
384
+ var sampleResearchCurve = (points, maximum = 1200) => {
385
+ if (!Number.isInteger(maximum) || maximum < 4)
386
+ throw new Error("Curve point budget must be at least four");
387
+ if (points.length <= maximum) return points;
388
+ const indexes = /* @__PURE__ */ new Set([0, points.length - 1]);
389
+ const buckets = Math.floor((maximum - 2) / 2);
390
+ const width = Math.ceil((points.length - 2) / buckets);
391
+ for (let start = 1; start < points.length - 1; start += width) {
392
+ const end = Math.min(points.length - 1, start + width);
393
+ let low = start, high = start;
394
+ for (let index = start + 1; index < end; index++) {
395
+ if (points[index][1] < points[low][1]) low = index;
396
+ if (points[index][1] > points[high][1]) high = index;
397
+ }
398
+ indexes.add(low);
399
+ indexes.add(high);
400
+ }
401
+ return [...indexes].sort((a, b) => a - b).map((index) => points[index]);
402
+ };
403
+ var researchLineChart = (series, title) => {
404
+ const populated = series.filter((item) => item.points.length);
405
+ if (!populated.length)
406
+ return `<p class="empty">${escapeResearchHtml(title)}: \u043D\u0435\u0442 \u043D\u0430\u0431\u043B\u044E\u0434\u0435\u043D\u0438\u0439.</p>`;
407
+ let minX = Infinity, maxX = -Infinity, minY = 0, maxY = 0;
408
+ for (const item of populated)
409
+ for (const [time2, value] of item.points) {
410
+ minX = Math.min(minX, time2);
411
+ maxX = Math.max(maxX, time2);
412
+ minY = Math.min(minY, value);
413
+ maxY = Math.max(maxY, value);
414
+ }
415
+ const span = Math.max(1e-9, maxY - minY), x = (time2) => 72 + 852 * (time2 - minX) / Math.max(1, maxX - minX), y = (value) => 238 - 196 * (value - minY) / span;
416
+ const paths = populated.map((item) => {
417
+ const points = sampleResearchCurve(item.points);
418
+ const path2 = points.map(
419
+ ([time2, value], index) => `${index ? "H" : "M"}${x(time2).toFixed(2)}${index ? "V" : ","}${y(value).toFixed(2)}`
420
+ ).join(" ");
421
+ return `<path d="${path2}" fill="none" stroke="${escapeResearchHtml(item.color)}" stroke-width="2.5" stroke-linejoin="round"/>`;
422
+ }).join("");
423
+ return `<figure class="chart"><figcaption>${escapeResearchHtml(title)}</figcaption><svg viewBox="0 0 960 285" role="img" aria-label="${escapeResearchHtml(title)}"><title>${escapeResearchHtml(title)}</title>${[
424
+ 4
425
+ ].map((index) => {
426
+ const value = minY + span * index / 4;
427
+ return `<path d="M72 ${y(value)}H924" stroke="#dce5ed"/><text x="60" y="${y(value) + 4}" text-anchor="end">${value.toFixed(1)}</text>`;
428
+ }).join(
429
+ ""
430
+ )}<path d="M72 ${y(0)}H924" stroke="#9aaebe"/>${paths}<text x="72" y="274">${new Date(minX).toISOString().slice(0, 10)}</text><text x="924" y="274" text-anchor="end">${new Date(maxX).toISOString().slice(0, 10)}</text></svg><div class="legend">${series.map((item) => `<span><i style="background:${escapeResearchHtml(item.color)}"></i>${escapeResearchHtml(item.label)}${item.points.length ? "" : " \xB7 \u043D\u0435\u0442 \u0441\u0434\u0435\u043B\u043E\u043A"}</span>`).join("")}</div><p class="chart-note">\u0417\u0430\u043A\u0440\u044B\u0442\u044B\u0435 \u0441\u0434\u0435\u043B\u043A\u0438 \xB7 USDT. \u0413\u0440\u0430\u0444\u0438\u043A \u0441\u043E\u0445\u0440\u0430\u043D\u044F\u0435\u0442 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0435 \u044D\u043A\u0441\u0442\u0440\u0435\u043C\u0443\u043C\u044B; \u0442\u043E\u0447\u043D\u044B\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F \u0445\u0440\u0430\u043D\u044F\u0442\u0441\u044F \u0432 JSON.</p></figure>`;
431
+ };
432
+
433
+ // src/lib/researchVisuals/render.ts
434
+ var fmt = (n) => n === null ? "unknown" : Number(n.toPrecision(6)).toString();
435
+ var utc = (n) => new Date(n).toISOString().slice(0, 16).replace("T", " ");
436
+ var text2 = (x, y, value, size = 15, color = "#213547") => `<text x="${x}" y="${y}" font-family="Arial,sans-serif" font-size="${size}" fill="${color}">${escapeResearchHtml(String(value))}</text>`;
437
+ function chartCandles(trade) {
438
+ const closes = [];
439
+ return trade.candles.map((b) => {
440
+ closes.push(b.close);
441
+ const sma = (n) => closes.length < n ? null : closes.slice(-n).reduce((s, v) => s + v, 0) / n;
442
+ const mid = sma(20), sd = mid === null ? null : Math.sqrt(
443
+ closes.slice(-20).reduce((s, v) => s + (v - mid) ** 2, 0) / 20
444
+ );
445
+ return {
446
+ ...b,
447
+ smaFast: b.smaFast ?? sma(14),
448
+ smaSlow: b.smaSlow ?? sma(50),
449
+ bbUpper: b.bbUpper ?? (mid !== null && sd !== null ? mid + 2 * sd : null),
450
+ bbLower: b.bbLower ?? (mid !== null && sd !== null ? mid - 2 * sd : null)
451
+ };
452
+ });
453
+ }
454
+ function pricePanel(t, c, step, x, y, w, h) {
455
+ if (!c.length) return text2(x, y + 30, "No cached candles in this window");
456
+ const times = c.map((b) => b.timestamp), start = times[0], end = times.at(-1) + step;
457
+ let low = Math.min(t.entryPrice, t.exitPrice, ...c.map((b) => b.low)), high = Math.max(t.entryPrice, t.exitPrice, ...c.map((b) => b.high));
458
+ for (const v of [t.stop, t.target])
459
+ if (v !== null) {
460
+ low = Math.min(low, v);
461
+ high = Math.max(high, v);
462
+ }
463
+ const pad = (high - low) * 0.05 || 1;
464
+ low -= pad;
465
+ high += pad;
466
+ const X = (v) => x + w * (v - start) / Math.max(1, end - start), Y = (v) => y + h - h * (v - low) / (high - low);
467
+ const id = `clip-${x}-${y}`;
468
+ let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="white" stroke="#cbd5df"/>`;
469
+ for (let i = 0; i <= 4; i++)
470
+ s += `<path d="M${x} ${y + h * i / 4}h${w}" stroke="#e5eaf0"/>` + text2(
471
+ x + w + 5,
472
+ y + h * i / 4 + 4,
473
+ fmt(high - (high - low) * i / 4),
474
+ 11
475
+ );
476
+ s += `<defs><clipPath id="${id}"><rect x="${x}" y="${y}" width="${w}" height="${h}"/></clipPath></defs><g clip-path="url(#${id})">`;
477
+ for (const z of t.figures.zones)
478
+ s += `<rect x="${Math.min(X(z.start.timestamp), X(z.end.timestamp))}" y="${Math.min(Y(z.start.value), Y(z.end.value))}" width="${Math.abs(X(z.end.timestamp) - X(z.start.timestamp))}" height="${Math.abs(Y(z.end.value) - Y(z.start.value))}" fill="#dceafa" opacity=".5"/>`;
479
+ const stride = Math.max(1, Math.ceil(c.length / 900)), width = Math.max(0.7, w * step / (end - start) * stride * 0.65);
480
+ for (let i = 0; i < c.length; i += stride) {
481
+ const a = c.slice(i, i + stride), first = a[0], last = a.at(-1), hi = Math.max(...a.map((b) => b.high)), lo = Math.min(...a.map((b) => b.low)), color = last.close >= first.open ? "#0a8069" : "#c73f52", xx = X(first.timestamp);
482
+ s += `<path d="M${xx} ${Y(lo)}V${Y(hi)}" stroke="${color}"/><rect x="${xx - width / 2}" y="${Math.min(Y(first.open), Y(last.close))}" width="${width}" height="${Math.max(0.8, Math.abs(Y(first.open) - Y(last.close)))}" fill="${color}"/>`;
483
+ }
484
+ for (const [key, color] of [
485
+ ["smaFast", "#d88b00"],
486
+ ["smaSlow", "#8555cc"],
487
+ ["bbUpper", "#8898ab"],
488
+ ["bbLower", "#8898ab"]
489
+ ]) {
490
+ let previous = null;
491
+ const path2 = c.map((b) => {
492
+ const v = b[key];
493
+ if (v == null) {
494
+ previous = null;
495
+ return "";
496
+ }
497
+ const command = previous === null || b.timestamp - previous !== step ? "M" : "L";
498
+ previous = b.timestamp;
499
+ return `${command}${X(b.timestamp)},${Y(v)}`;
500
+ }).join(" ");
501
+ s += `<path d="${path2}" fill="none" stroke="${color}" stroke-width="1.4"/>`;
502
+ }
503
+ for (const line of t.figures.lines)
504
+ s += `<polyline points="${line.points.map((p) => `${X(p.timestamp)},${Y(p.value)}`).join(" ")}" fill="none" stroke="#0284c7" stroke-width="2"/>`;
505
+ for (const group of t.figures.points)
506
+ for (const p of group.points)
507
+ s += `<circle cx="${X(p.timestamp)}" cy="${Y(p.value)}" r="3" fill="#0284c7"/>`;
508
+ for (const [label, value, color] of [
509
+ ["SL", t.stop, "#c73f52"],
510
+ ["TP", t.target, "#0a8069"]
511
+ ])
512
+ if (value !== null)
513
+ s += `<path d="M${x} ${Y(value)}h${w}" stroke="${color}" stroke-dasharray="5 4"/>` + text2(x + 4, Y(value) - 5, label, 12, color);
514
+ for (const [i, [label, timestamp, value]] of [
515
+ ["SIGNAL", t.signalTime, null],
516
+ ["ENTRY", t.entryTime, t.entryPrice],
517
+ ["EXIT", t.exitTime, t.exitPrice]
518
+ ].entries())
519
+ if (timestamp >= start && timestamp <= end) {
520
+ s += `<path d="M${X(timestamp)} ${y}v${h}" stroke="#2563eb" stroke-dasharray="3 4"/>` + text2(
521
+ Math.max(x + 3, Math.min(x + w - 75, X(timestamp) + 4)),
522
+ y + 17 + i * 17,
523
+ label,
524
+ 12
525
+ );
526
+ if (value !== null)
527
+ s += `<circle cx="${X(timestamp)}" cy="${Y(value)}" r="5" fill="white" stroke="#2563eb" stroke-width="2"/>`;
528
+ }
529
+ return s + "</g>" + text2(x, y + h + 20, utc(start), 12) + text2(x + w - 145, y + h + 20, utc(end), 12) + text2(
530
+ x,
531
+ y - 8,
532
+ `OHLC aggregation: ${stride} native bars per displayed candle`,
533
+ 11
534
+ );
535
+ }
536
+ function renderTradeSvg(t, intervalMs) {
537
+ const candles = chartCandles(t);
538
+ const start = Math.min(
539
+ t.signalTime - 90 * intervalMs,
540
+ t.entryTime - 90 * intervalMs
541
+ ), end = t.exitTime + 20 * intervalMs;
542
+ const visible = candles.filter(
543
+ (c) => c.timestamp >= start && c.timestamp <= end
544
+ );
545
+ const held = candles.filter(
546
+ (b) => b.timestamp >= t.entryTime && b.timestamp + intervalMs <= t.exitTime
547
+ ), risk = t.stop === null ? null : Math.abs(t.entryPrice - t.stop), sign = t.direction === "LONG" ? 1 : -1;
548
+ const expected = visible.length ? Math.floor(
549
+ (visible.at(-1).timestamp - visible[0].timestamp) / intervalMs
550
+ ) + 1 : 0;
551
+ const complete = held.length > 0 && held[0].timestamp === t.entryTime && held.at(-1).timestamp + intervalMs === t.exitTime && held.every(
552
+ (b, i) => !i || b.timestamp - held[i - 1].timestamp === intervalMs
553
+ );
554
+ const mfe = complete && risk ? Math.max(
555
+ 0,
556
+ ...held.map(
557
+ (b) => (sign === 1 ? b.high - t.entryPrice : t.entryPrice - b.low) / risk
558
+ )
559
+ ) : null, mae = complete && risk ? Math.max(
560
+ 0,
561
+ ...held.map(
562
+ (b) => (sign === 1 ? t.entryPrice - b.low : b.high - t.entryPrice) / risk
563
+ )
564
+ ) : null;
565
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1050" viewBox="0 0 1600 1050"><rect width="1600" height="1050" fill="#f3f6fa"/>`;
566
+ svg += text2(40, 35, `${t.strategy} \xB7 ${t.symbol} \xB7 ${t.direction}`, 25) + text2(
567
+ 40,
568
+ 64,
569
+ `${t.pnl > 0 ? "WIN" : t.pnl < 0 ? "LOSS" : "FLAT"} \xB7 net ${fmt(t.pnl)} USDT \xB7 ${utc(t.entryTime)} \u2192 ${utc(t.exitTime)} UTC`,
570
+ 17
571
+ );
572
+ svg += text2(
573
+ 40,
574
+ 88,
575
+ "Saved indicator series when supplied; otherwise analytical SMA14/50 and BB20 \xB12\u03C3 (not strategy calculations).",
576
+ 12
577
+ );
578
+ svg += pricePanel(t, visible, intervalMs, 40, 120, 1440, 330);
579
+ const maxVolume = Math.max(1, ...visible.map((b) => b.volume));
580
+ svg += text2(40, 497, "Native-bar volume", 13);
581
+ for (const b of visible) {
582
+ const x = 40 + 1440 * (b.timestamp - (visible[0]?.timestamp ?? 0)) / Math.max(
583
+ 1,
584
+ (visible.at(-1)?.timestamp ?? 0) + intervalMs - (visible[0]?.timestamp ?? 0)
585
+ );
586
+ svg += `<path d="M${x} 575v${-65 * b.volume / maxVolume}" stroke="#8cabbc"/>`;
587
+ }
588
+ svg += text2(
589
+ 40,
590
+ 615,
591
+ "Entry detail: signal and actual fill are separate events",
592
+ 15
593
+ ) + pricePanel(
594
+ t,
595
+ candles.filter(
596
+ (b) => b.timestamp >= t.signalTime - 30 * intervalMs && b.timestamp <= t.entryTime + 12 * intervalMs
597
+ ),
598
+ intervalMs,
599
+ 40,
600
+ 645,
601
+ 960,
602
+ 260
603
+ );
604
+ const details = [
605
+ `Entry ${fmt(t.entryPrice)} / exit ${fmt(t.exitPrice)}`,
606
+ `MFE ${fmt(mfe)} R / MAE ${fmt(mae)} R`,
607
+ `Full held bars available: ${complete}`,
608
+ `Missing display bars: ${expected - visible.length}`,
609
+ `Pockets: ${Object.entries(t.pockets).map(([k, v]) => `${k}=${v ? "pass" : "fail"}`).join(", ") || "unknown"}`
610
+ ];
611
+ details.forEach(
612
+ (v, i) => svg += text2(
613
+ 1080,
614
+ 665 + i * 38,
615
+ v.length > 58 ? v.slice(0, 55) + "\u2026" : v,
616
+ 13
617
+ )
618
+ );
619
+ svg += text2(
620
+ 40,
621
+ 960,
622
+ "MFE/MAE exclude partial entry/exit bars and require complete held coverage; these are retrospective diagnostics.",
623
+ 13
624
+ ) + text2(40, 988, `Trade ID: ${t.id}`.slice(0, 180), 12) + text2(
625
+ 40,
626
+ 1015,
627
+ "Outcome-balanced charts describe cases, not the population win rate. Pocket overlays do not simulate skipped entries.",
628
+ 12
629
+ );
630
+ return {
631
+ svg: svg + "</svg>",
632
+ audit: {
633
+ visibleCandles: visible.length,
634
+ missingCandles: expected - visible.length,
635
+ completeHeld: complete,
636
+ mfeR: mfe,
637
+ maeR: mae,
638
+ entryCovered: visible.some(
639
+ (b) => b.timestamp <= t.entryTime && b.timestamp + intervalMs > t.entryTime
640
+ ),
641
+ exitCovered: visible.some(
642
+ (b) => b.timestamp <= t.exitTime && b.timestamp + intervalMs > t.exitTime
643
+ )
644
+ }
645
+ };
646
+ }
647
+ function renderDrawdownSvg(episode) {
648
+ const curve = researchLineChart(
649
+ [
650
+ { label: "Realized equity", points: episode.points, color: "#2563eb" },
651
+ {
652
+ label: "Prior peak",
653
+ points: [
654
+ [episode.start, episode.peak],
655
+ [episode.end, episode.peak]
656
+ ],
657
+ color: "#c73f52"
658
+ }
659
+ ],
660
+ episode.id
661
+ );
662
+ const plot = curve.slice(curve.indexOf("<svg"), curve.indexOf("</svg>") + 6).replace(
663
+ 'viewBox="0 0 960 285"',
664
+ 'x="20" y="100" width="1160" height="360" viewBox="0 0 960 285"'
665
+ );
666
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="700"><rect width="1200" height="700" fill="#f3f6fa"/>${text2(35, 35, `${episode.id}: depth ${fmt(episode.depth)} USDT`, 24)}${text2(35, 65, `${utc(episode.start)} \u2192 ${utc(episode.end)} UTC \xB7 ${episode.recovered ? "recovered" : "unrecovered / right-censored"}`, 16)}${plot}${Object.entries(
667
+ episode.contributions
668
+ ).filter(([k]) => k.startsWith("symbol:")).sort((a, b) => a[1].pnl - b[1].pnl).slice(0, 5).map(
669
+ ([k, v], i) => text2(40, 500 + i * 30, `${k} \xB7 N=${v.n} \xB7 PnL=${fmt(v.pnl)}`, 16)
670
+ ).join(
671
+ ""
672
+ )}${text2(35, 675, "Completed-trade equity; simultaneous exits netted. Includes recovery trades. Not mark-to-market drawdown.", 13)}</svg>`;
673
+ }
674
+
675
+ // src/lib/researchVisuals.ts
676
+ var sha = (v) => (0, import_node_crypto2.createHash)("sha256").update(v).digest("hex");
677
+ async function readInput(file) {
678
+ const stat = await import_promises.default.stat(file);
679
+ if (stat.size > 512 * 1024 * 1024)
680
+ throw new Error("Visual input exceeds 512 MiB");
681
+ const bytes = await import_promises.default.readFile(file), decoded = file.endsWith(".gz") ? (0, import_node_zlib.gunzipSync)(bytes, { maxOutputLength: 512 * 1024 * 1024 }) : bytes;
682
+ const source = decoded.toString("utf8");
683
+ const value = /\.jsonl(?:\.gz)?$/.test(file) ? source.split(/\r?\n/).filter((l) => l.trim()).map((l) => JSON.parse(l)) : JSON.parse(source);
684
+ return {
685
+ value,
686
+ receipt: {
687
+ path: import_node_path.default.resolve(file),
688
+ sha256: sha(bytes),
689
+ bytes: bytes.length
690
+ }
691
+ };
692
+ }
693
+ async function generateResearchVisuals(options) {
694
+ if (!options.inputs.length) throw new Error("At least one input is required");
695
+ const inputs = await Promise.all(options.inputs.map(readInput));
696
+ if (inputs.length > 1 && inputs.some((i) => !Array.isArray(i.value)))
697
+ throw new Error(
698
+ "Multiple inputs are supported only for JSONL/array shards"
699
+ );
700
+ const candles = options.candlesFile ? await readInput(options.candlesFile) : void 0;
701
+ const dataset = normalizeDataset(
702
+ inputs.length === 1 ? inputs[0].value : inputs.flatMap((i) => Array.isArray(i.value) ? i.value : []),
703
+ {
704
+ population: options.population,
705
+ intervalMs: options.intervalMs,
706
+ strategy: options.strategy,
707
+ candlesBySymbol: candles ? object(candles.value) : void 0
708
+ }
709
+ );
710
+ const selected = selectAtlas(
711
+ dataset.trades,
712
+ options.count ?? 100,
713
+ options.seed ?? "trade-atlas/v1"
714
+ );
715
+ const episodes = dataset.population === "complete" ? Object.fromEntries(
716
+ ["ALL", "LONG", "SHORT"].map((direction) => [
717
+ direction,
718
+ drawdownEpisodes({
719
+ ...dataset,
720
+ trades: dataset.trades.filter(
721
+ (t) => direction === "ALL" || t.direction === direction
722
+ )
723
+ })
724
+ ])
725
+ ) : null;
726
+ const analysis = Object.fromEntries(
727
+ [...new Set(dataset.trades.map((t) => t.strategy))].sort().map((strategy) => [
728
+ strategy,
729
+ analyzeVisualTrades(
730
+ dataset.trades.filter((t) => t.strategy === strategy)
731
+ )
732
+ ])
733
+ );
734
+ const out = import_node_path.default.resolve(options.outDir);
735
+ await import_promises.default.mkdir(import_node_path.default.dirname(out), { recursive: true });
736
+ await import_promises.default.mkdir(out);
737
+ const receipts = {};
738
+ const write = async (name, data) => {
739
+ const bytes = typeof data === "string" ? Buffer.from(data) : data;
740
+ await import_promises.default.writeFile(import_node_path.default.join(out, name), bytes, { flag: "wx" });
741
+ receipts[name] = { sha256: sha(bytes), bytes: bytes.length };
742
+ };
743
+ const manifest = {
744
+ schema: "tradejs-research-visual-report/v1",
745
+ status: "running",
746
+ source: dataset.source,
747
+ population: dataset.population,
748
+ populationClaim: dataset.population === "complete" ? "Caller declares complete reconciled population" : "Selected cases; no population or drawdown inference",
749
+ inputs: inputs.map((i) => i.receipt),
750
+ candles: candles?.receipt,
751
+ selection: {
752
+ countPerOutcomePerStrategy: options.count ?? 100,
753
+ seed: options.seed ?? "trade-atlas/v1",
754
+ method: "Equal chronological strata; SHA256 choice within stratum; no replacement"
755
+ },
756
+ summary: summarize(dataset.trades),
757
+ selected: selected.length,
758
+ semantics: "Retrospective associations, not causal validation. Pocket labels are supplied snapshots, not a native portfolio rerun."
759
+ };
760
+ await import_promises.default.writeFile(
761
+ import_node_path.default.join(out, "manifest.json"),
762
+ JSON.stringify(manifest, null, 2)
763
+ );
764
+ try {
765
+ await import_promises.default.mkdir(import_node_path.default.join(out, "trades"));
766
+ await import_promises.default.mkdir(import_node_path.default.join(out, "drawdowns"));
767
+ const cards = [];
768
+ for (const [index, trade] of selected.entries()) {
769
+ const name = `trades/${String(index + 1).padStart(4, "0")}-${sha(trade.strategy + ":" + trade.id).slice(0, 12)}`;
770
+ const { svg, audit } = renderTradeSvg(trade, dataset.intervalMs);
771
+ await write(name + ".svg", svg);
772
+ if (options.png)
773
+ await write(
774
+ name + ".png",
775
+ await (0, import_sharp.default)(Buffer.from(svg)).png().toBuffer()
776
+ );
777
+ cards.push({
778
+ id: trade.id,
779
+ strategy: trade.strategy,
780
+ symbol: trade.symbol,
781
+ direction: trade.direction,
782
+ pnl: trade.pnl,
783
+ outcome: trade.pnl > 0 ? "win" : "loss",
784
+ pockets: trade.pockets,
785
+ svg: name + ".svg",
786
+ image: name + (options.png ? ".png" : ".svg"),
787
+ audit
788
+ });
789
+ }
790
+ const drawdownCards = [];
791
+ for (const [direction, group] of Object.entries(episodes ?? {}))
792
+ for (const episode of group) {
793
+ const name = `drawdowns/${direction}-${episode.id}`;
794
+ const svg = renderDrawdownSvg(episode);
795
+ await write(name + ".svg", svg);
796
+ if (options.png)
797
+ await write(
798
+ name + ".png",
799
+ await (0, import_sharp.default)(Buffer.from(svg)).png().toBuffer()
800
+ );
801
+ drawdownCards.push({
802
+ ...episode,
803
+ direction,
804
+ image: name + (options.png ? ".png" : ".svg")
805
+ });
806
+ }
807
+ const retained = {
808
+ ...dataset,
809
+ population: "selected",
810
+ trades: selected.map((trade) => ({
811
+ ...trade,
812
+ candles: trade.candles.filter(
813
+ (bar) => bar.timestamp >= trade.signalTime - 140 * dataset.intervalMs && bar.timestamp <= trade.exitTime + 20 * dataset.intervalMs
814
+ )
815
+ }))
816
+ };
817
+ await write("chart-inputs.json.gz", (0, import_node_zlib.gzipSync)(JSON.stringify(retained)));
818
+ await write(
819
+ "analysis.json",
820
+ JSON.stringify(
821
+ {
822
+ schema: "tradejs-visual-analysis/v1",
823
+ population: dataset.population,
824
+ analysis,
825
+ drawdowns: episodes,
826
+ cards
827
+ },
828
+ null,
829
+ 2
830
+ )
831
+ );
832
+ const option = (value) => `<option value="${escapeResearchHtml(value)}">${escapeResearchHtml(value)}</option>`;
833
+ const selectors = [
834
+ ["strategy", [...new Set(cards.map((c) => c.strategy))]],
835
+ ["outcome", ["win", "loss"]],
836
+ ["direction", ["LONG", "SHORT"]],
837
+ ["pocket", [...new Set(cards.flatMap((c) => Object.keys(c.pockets)))]]
838
+ ].map(
839
+ ([key, values]) => `<label>${key} <select id="${key}"><option value="">all</option>${values.map(option).join("")}</select></label>`
840
+ ).join(" ");
841
+ const contrastTables = Object.entries(analysis).map(
842
+ ([strategy, bySide]) => `<h3>${escapeResearchHtml(strategy)}</h3>${Object.entries(bySide).map(
843
+ ([side, info]) => `<h4>${side}</h4><table><tr><th>Pocket</th><th>Pass N / PnL</th><th>Fail N / PnL</th><th>Unknown</th></tr>${Object.entries(
844
+ info.pockets
845
+ ).map(
846
+ ([name, p]) => `<tr><td>${escapeResearchHtml(name)}</td><td>${p.pass.n} / ${p.pass.pnl.toFixed(2)}</td><td>${p.fail.n} / ${p.fail.pnl.toFixed(2)}</td><td>${p.unknown}</td></tr>`
847
+ ).join(
848
+ ""
849
+ )}</table><table><tr><th>Feature</th><th>Win / loss N</th><th>Win median</th><th>Loss median</th><th>Difference / IQR</th><th>Missing</th></tr>${info.contrasts.filter(
850
+ (c) => c.wins >= 5 && c.losses >= 5 && c.medianDifferenceIqr !== null
851
+ ).sort(
852
+ (a, b) => Math.abs(b.medianDifferenceIqr) - Math.abs(a.medianDifferenceIqr)
853
+ ).slice(0, 20).map(
854
+ (c) => `<tr><td>${escapeResearchHtml(c.feature)}</td><td>${c.wins} / ${c.losses}</td><td>${c.winMedian}</td><td>${c.lossMedian}</td><td>${c.medianDifferenceIqr?.toFixed(2)}</td><td>${c.missing}</td></tr>`
855
+ ).join("")}</table>`
856
+ ).join("")}`
857
+ ).join("");
858
+ const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Trade atlas and drawdown groups</title><style>body{font:16px system-ui;color:#213547;background:#f3f6fa;margin:28px}h1{font-size:30px}section{margin:32px 0}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:18px}article{background:white;padding:16px;border-radius:12px}img{width:100%}select{padding:8px}table{border-collapse:collapse;background:white;margin:16px 0;font-size:14px}td,th{padding:8px;border:1px solid #d9e2ec;text-align:left}a{color:#175eb9}.note{max-width:1100px}article[hidden]{display:none}</style><h1>Trade atlas and drawdown groups</h1><p class="note">${escapeResearchHtml(manifest.populationClaim)}. Source: ${escapeResearchHtml(dataset.source)}. ${escapeResearchHtml(manifest.semantics)}</p><p>Input N=${dataset.trades.length}; selected charts=${cards.length}. Balanced win/loss selection is not a win-rate estimate. Associations below use ${dataset.population === "complete" ? "all supplied completed trades" : "selected cases only, with selection bias"}.</p><p><a href="analysis.json">Full analysis and coverage</a> \xB7 <a href="manifest.json">Provenance</a> \xB7 <a href="chart-inputs.json.gz">Retained chart inputs</a></p><section><h2>Trade cases</h2>${selectors}<div class="grid">${cards.map((c) => `<article class="trade" data-strategy="${escapeResearchHtml(c.strategy)}" data-outcome="${c.outcome}" data-direction="${c.direction}" data-pockets="${escapeResearchHtml(JSON.stringify(c.pockets))}"><h3>${escapeResearchHtml(c.strategy)} \xB7 ${escapeResearchHtml(c.symbol)} \xB7 ${c.direction} \xB7 ${c.pnl.toFixed(2)}</h3><a href="${c.image}"><img loading="lazy" src="${c.image}" alt="${escapeResearchHtml(c.id)}"></a><p>${escapeResearchHtml(JSON.stringify(c.pockets))}</p><p>Missing bars: ${c.audit.missingCandles}; entry covered: ${c.audit.entryCovered}; exit covered: ${c.audit.exitCovered}</p><a href="${c.svg}">SVG</a></article>`).join("")}</div></section><section><h2>Pockets and feature contrasts</h2><p>Descriptive differences only. No optimized threshold, significance or profitability claim. Tables show features with at least 5 wins and 5 losses; JSON retains all support counts.</p>${contrastTables}</section><section><h2>Drawdown episodes</h2><p>${episodes ? "Completed-trade equity; same-time exits netted. ALL is portfolio equity, LONG/SHORT are side-only equity. Group contributions overlap across dimensions and include recovery trades." : "Unavailable: selected charts cannot reconstruct portfolio drawdowns."}</p><div class="grid">${drawdownCards.map(
859
+ (e) => `<article><h3>${e.direction} \xB7 ${e.id} \xB7 ${e.depth.toFixed(2)} USDT</h3><a href="${e.image}"><img loading="lazy" src="${e.image}" alt="${e.id}"></a><details><summary>Group contributions</summary><table><tr><th>Group</th><th>N</th><th>PnL</th></tr>${Object.entries(
860
+ e.contributions
861
+ ).map(
862
+ ([name, v]) => `<tr><td>${escapeResearchHtml(name)}</td><td>${v.n}</td><td>${v.pnl.toFixed(2)}</td></tr>`
863
+ ).join("")}</table></details></article>`
864
+ ).join(
865
+ ""
866
+ )}</div></section><script>for(const id of ['strategy','outcome','direction','pocket'])document.getElementById(id).addEventListener('change',()=>{for(const card of document.querySelectorAll('.trade'))card.hidden=['strategy','outcome','direction'].some(k=>document.getElementById(k).value&&card.dataset[k]!==document.getElementById(k).value)||(document.getElementById('pocket').value&&JSON.parse(card.dataset.pockets)[document.getElementById('pocket').value]!==true);});</script></html>`;
867
+ await write("index.html", html);
868
+ await import_promises.default.writeFile(
869
+ import_node_path.default.join(out, "manifest.json"),
870
+ JSON.stringify(
871
+ { ...manifest, status: "complete", files: receipts },
872
+ null,
873
+ 2
874
+ )
875
+ );
876
+ return {
877
+ outDir: out,
878
+ selected: cards.length,
879
+ episodes: drawdownCards.length
880
+ };
881
+ } catch (error) {
882
+ await import_promises.default.writeFile(
883
+ import_node_path.default.join(out, "manifest.json"),
884
+ JSON.stringify(
885
+ {
886
+ ...manifest,
887
+ status: "failed",
888
+ error: error instanceof Error ? error.message : String(error)
889
+ },
890
+ null,
891
+ 2
892
+ )
893
+ );
894
+ throw error;
895
+ }
896
+ }
897
+
898
+ // src/scripts/researchVisuals.ts
899
+ async function main() {
900
+ const args = process.argv.slice(2);
901
+ const options = { inputs: [], outDir: "" };
902
+ for (let i = 0; i < args.length; i++) {
903
+ const key = args[i];
904
+ if (key === "research-visuals") continue;
905
+ if (key === "--help") {
906
+ console.log(
907
+ "tradejs research-visuals --input FILE [--input SHARD] --outDir NEW_DIR [--population complete|selected] [--intervalMs N] [--strategy NAME] [--candles FILE] [--count 100] [--seed TEXT] [--png]"
908
+ );
909
+ return;
910
+ }
911
+ if (key === "--png") {
912
+ options.png = true;
913
+ continue;
914
+ }
915
+ const value = args[++i];
916
+ if (!value || value.startsWith("--"))
917
+ throw new Error(`Value required: ${key}`);
918
+ switch (key) {
919
+ case "--input":
920
+ options.inputs.push(value);
921
+ break;
922
+ case "--outDir":
923
+ options.outDir = value;
924
+ break;
925
+ case "--population":
926
+ if (value !== "complete" && value !== "selected")
927
+ throw new Error("Invalid population");
928
+ options.population = value;
929
+ break;
930
+ case "--intervalMs":
931
+ options.intervalMs = Number(value);
932
+ break;
933
+ case "--count":
934
+ options.count = Number(value);
935
+ break;
936
+ case "--seed":
937
+ options.seed = value;
938
+ break;
939
+ case "--strategy":
940
+ options.strategy = value;
941
+ break;
942
+ case "--candles":
943
+ options.candlesFile = value;
944
+ break;
945
+ default:
946
+ throw new Error(`Unknown option ${key}`);
947
+ }
948
+ }
949
+ if (!options.outDir) throw new Error("--outDir is required");
950
+ console.log(JSON.stringify(await generateResearchVisuals(options)));
951
+ }
952
+ // Annotate the CommonJS export names for ESM import in node:
953
+ 0 && (module.exports = {
954
+ main
955
+ });