@solard/cli 0.2.13 → 0.2.15

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,533 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import {
4
+ backtestTargetWeightToken,
5
+ backtestTokenTrades,
6
+ getTokenHistoryCoverage,
7
+ loadTokenHistoryTrades,
8
+ type AthDipProfitStrategy,
9
+ type TargetWeightPolicy,
10
+ type TokenAthDipProfitBacktestResult,
11
+ type TokenTargetWeightBacktestResult,
12
+ } from "@solard/sdk";
13
+
14
+ type Flags = Map<string, string>;
15
+ type Emit = (value: string) => void;
16
+ type CliStrategy = AthDipProfitStrategy | TargetWeightPolicy;
17
+ type CliBacktestResult =
18
+ TokenAthDipProfitBacktestResult | TokenTargetWeightBacktestResult;
19
+
20
+ type ReplayWindow = {
21
+ fromMs?: number;
22
+ toMs?: number;
23
+ fromLabel: string;
24
+ toLabel: string;
25
+ };
26
+
27
+ function flag(flags: Flags, key: string): string | undefined {
28
+ const value = flags.get(key);
29
+ return value && value !== "true" ? value : undefined;
30
+ }
31
+
32
+ function numberFlag(
33
+ flags: Flags,
34
+ key: string,
35
+ fallback?: number,
36
+ ): number | undefined {
37
+ const value = flag(flags, key);
38
+ if (value == null) return fallback;
39
+ const parsed = Number(value);
40
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
41
+ return parsed;
42
+ }
43
+
44
+ function parseDurationMs(
45
+ value: string | undefined,
46
+ fallbackMs: number,
47
+ ): number {
48
+ if (!value) return fallbackMs;
49
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value.trim());
50
+ if (!match)
51
+ throw new Error(`Invalid duration: ${value}. Use e.g. 500ms, 5s, 5m, 1h.`);
52
+ const n = Number(match[1]);
53
+ const unit = (match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h";
54
+ const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[unit];
55
+ return Math.max(1_000, Math.trunc(n * scale));
56
+ }
57
+
58
+ function parseReplayTime(value: string, label: string): number {
59
+ const trimmed = value.trim();
60
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
61
+ const numeric = Number(trimmed);
62
+ if (!Number.isFinite(numeric) || numeric < 0)
63
+ throw new Error(`Invalid ${label}: ${value}`);
64
+ return numeric < 10_000_000_000
65
+ ? Math.trunc(numeric * 1_000)
66
+ : Math.trunc(numeric);
67
+ }
68
+ const parsed = Date.parse(trimmed);
69
+ if (!Number.isFinite(parsed)) {
70
+ throw new Error(
71
+ `Invalid ${label}: ${value}. Use creation, migration, an ISO timestamp, Unix seconds, or Unix milliseconds.`,
72
+ );
73
+ }
74
+ return parsed;
75
+ }
76
+
77
+ function resolveReplayWindow(mint: string, flags: Flags): ReplayWindow {
78
+ const coverage = getTokenHistoryCoverage(mint);
79
+ const fromRaw = flag(flags, "from") ?? "creation";
80
+ let fromMs: number | undefined;
81
+ let fromLabel: string;
82
+
83
+ if (fromRaw === "creation") {
84
+ fromMs = coverage?.creationAtMs ?? undefined;
85
+ fromLabel =
86
+ fromMs == null
87
+ ? "creation requested; local creation timestamp unavailable, using first usable event"
88
+ : `creation ${new Date(fromMs).toISOString()}`;
89
+ } else if (fromRaw === "migration" || fromRaw === "pumpswap") {
90
+ const firstPumpSwap = loadTokenHistoryTrades(mint).find(
91
+ (row) =>
92
+ row.history?.venue === "pumpswap" || row.source === "history:pumpswap",
93
+ );
94
+ const coverageFallback =
95
+ coverage?.pumpswap?.oldestBlockTime != null
96
+ ? coverage.pumpswap.oldestBlockTime * 1_000
97
+ : undefined;
98
+ fromMs = firstPumpSwap?.tradedAtMs ?? coverageFallback;
99
+ if (fromMs == null) {
100
+ throw new Error(
101
+ `--from ${fromRaw} requested, but no PumpSwap/migration boundary is present in durable history for ${mint}.`,
102
+ );
103
+ }
104
+ fromLabel = `migration / first PumpSwap activity ${new Date(fromMs).toISOString()}`;
105
+ } else {
106
+ fromMs = parseReplayTime(fromRaw, "--from");
107
+ fromLabel = `explicit ${new Date(fromMs).toISOString()}`;
108
+ }
109
+
110
+ const toRaw = flag(flags, "to");
111
+ const toMs = toRaw == null ? undefined : parseReplayTime(toRaw, "--to");
112
+ if (fromMs != null && toMs != null && toMs < fromMs) {
113
+ throw new Error("--to must be at or after --from");
114
+ }
115
+ return {
116
+ fromMs,
117
+ toMs,
118
+ fromLabel,
119
+ toLabel:
120
+ toMs == null ? "end of durable tape" : new Date(toMs).toISOString(),
121
+ };
122
+ }
123
+
124
+ function applyExecutionOverrides(
125
+ strategy: CliStrategy,
126
+ flags: Flags,
127
+ ): CliStrategy {
128
+ const execution: Record<string, number | undefined> = {
129
+ ...(strategy.execution ?? {}),
130
+ };
131
+ if (flag(flags, "slippage-bps") != null)
132
+ execution.slippageBps = numberFlag(flags, "slippage-bps");
133
+ if (flag(flags, "venue-fee-bps") != null)
134
+ execution.venueFeeBps = numberFlag(flags, "venue-fee-bps");
135
+ if (flag(flags, "network-fee-sol") != null)
136
+ execution.networkFeeSol = numberFlag(flags, "network-fee-sol");
137
+ if (flag(flags, "latency-ms") != null)
138
+ execution.latencyMs = numberFlag(flags, "latency-ms");
139
+ return { ...strategy, execution } as CliStrategy;
140
+ }
141
+
142
+ function targetWeightStrategy(flags: Flags): TargetWeightPolicy {
143
+ const targetWeightPct = numberFlag(flags, "target-weight", 40)!;
144
+ const gapMode = flag(flags, "gap-mode") ?? "fixed";
145
+ if (gapMode !== "fixed" && gapMode !== "previous-5m-vol") {
146
+ throw new Error("--gap-mode must be fixed or previous-5m-vol");
147
+ }
148
+ return {
149
+ version: 1,
150
+ kind: "target-weight",
151
+ name:
152
+ gapMode === "fixed"
153
+ ? `Target weight ${targetWeightPct}% / ${numberFlag(flags, "gap-pct", 3)}pp band`
154
+ : `Target weight ${targetWeightPct}% / adaptive 5m-vol band`,
155
+ targetWeightPct,
156
+ gap: {
157
+ mode: gapMode,
158
+ outerPct: numberFlag(flags, "gap-pct", 3)!,
159
+ innerPct: numberFlag(flags, "inner-gap-pct", 1)!,
160
+ volatilityMultiplier: numberFlag(flags, "vol-multiplier", 2),
161
+ minPct: numberFlag(flags, "min-gap-pct", 1),
162
+ maxPct: numberFlag(flags, "max-gap-pct", 8),
163
+ },
164
+ minTradeSol: numberFlag(flags, "min-trade-sol", 0.001),
165
+ execution: {
166
+ slippageBps: numberFlag(flags, "slippage-bps", 0),
167
+ venueFeeBps: numberFlag(flags, "venue-fee-bps", 0),
168
+ networkFeeSol: numberFlag(flags, "network-fee-sol", 0),
169
+ latencyMs: numberFlag(flags, "latency-ms", 0),
170
+ },
171
+ };
172
+ }
173
+
174
+ function readStrategy(flags: Flags): CliStrategy {
175
+ const requested = flag(flags, "strategy");
176
+ if (requested === "target-weight" || flags.has("target-weight")) {
177
+ return targetWeightStrategy(flags);
178
+ }
179
+ if (requested && requested !== "ath-dip-profit-ladder") {
180
+ const parsed = JSON.parse(
181
+ readFileSync(resolve(requested), "utf8"),
182
+ ) as CliStrategy;
183
+ return applyExecutionOverrides(parsed, flags);
184
+ }
185
+
186
+ const dipPct = numberFlag(flags, "dip-pct");
187
+ const profitPct = numberFlag(flags, "profit-pct");
188
+ const buySol = numberFlag(flags, "buy-sol");
189
+ if (dipPct == null || profitPct == null || buySol == null) {
190
+ throw new Error(
191
+ "Usage:\n" +
192
+ " slrd backtest <mint> --strategy target-weight --target-weight 40 --gap-pct 3 --inner-gap-pct 1 [options]\n" +
193
+ " slrd backtest <mint> --strategy target-weight --gap-mode previous-5m-vol --vol-multiplier 2 [options]\n" +
194
+ " slrd backtest <mint> --dip-pct 20 --profit-pct 40 --buy-sol 0.1 [options]\n" +
195
+ " slrd backtest <mint> --strategy <file.json> [options]",
196
+ );
197
+ }
198
+ const metricFlag = flag(flags, "metric") ?? "price-sol";
199
+ const metric = metricFlag === "market-cap-usd" ? "marketCapUsd" : "priceSol";
200
+ if (metricFlag !== "price-sol" && metricFlag !== "market-cap-usd") {
201
+ throw new Error("--metric must be price-sol or market-cap-usd");
202
+ }
203
+ return {
204
+ version: 1,
205
+ kind: "ath-dip-profit-ladder",
206
+ name: `ATH dip ${dipPct}% / TP ${profitPct}%`,
207
+ metric,
208
+ entry: {
209
+ stepPct: dipPct,
210
+ buySol,
211
+ ...(numberFlag(flags, "max-levels") != null
212
+ ? { maxLevels: numberFlag(flags, "max-levels") }
213
+ : {}),
214
+ catchUpLevels: !flags.has("no-catch-up"),
215
+ },
216
+ exit: { profitPct },
217
+ execution: {
218
+ slippageBps: numberFlag(flags, "slippage-bps", 0),
219
+ venueFeeBps: numberFlag(flags, "venue-fee-bps", 0),
220
+ networkFeeSol: numberFlag(flags, "network-fee-sol", 0),
221
+ latencyMs: numberFlag(flags, "latency-ms", 0),
222
+ },
223
+ };
224
+ }
225
+
226
+ function json(value: unknown): string {
227
+ return JSON.stringify(value, null, 2);
228
+ }
229
+
230
+ function fmtSol(value: number): string {
231
+ return `${value >= 0 ? "" : "-"}${Math.abs(value).toFixed(6)} SOL`;
232
+ }
233
+
234
+ function fmtPct(value: number | null): string {
235
+ return value == null ? "n/a" : `${value.toFixed(2)}%`;
236
+ }
237
+
238
+ function fmtTime(ms: number | null): string {
239
+ if (ms == null) return "n/a";
240
+ if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
241
+ if (ms < 3_600_000) return `${(ms / 60_000).toFixed(1)}m`;
242
+ if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(1)}h`;
243
+ return `${(ms / 86_400_000).toFixed(2)}d`;
244
+ }
245
+
246
+ function csvCell(value: unknown): string {
247
+ const text = value == null ? "" : String(value);
248
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
249
+ }
250
+
251
+ function executionLedgerCsv(result: CliBacktestResult): string {
252
+ if (result.strategy.kind === "target-weight") {
253
+ const target = result as TokenTargetWeightBacktestResult;
254
+ const rows = target.executions.map((row) => ({
255
+ at: new Date(row.atMs).toISOString(),
256
+ triggerAt: new Date(row.triggerAtMs).toISOString(),
257
+ side: row.side,
258
+ status: row.status,
259
+ observedPriceSol: row.observedPriceSol,
260
+ executionPriceSol: row.executionPriceSol,
261
+ amountSol: row.amountSol,
262
+ tokens: row.tokens,
263
+ targetWeightPct: row.targetWeightPct,
264
+ desiredWeightPct: row.desiredWeightPct,
265
+ weightBeforePct: row.weightBeforePct,
266
+ weightAfterPct: row.weightAfterPct,
267
+ outerGapPct: row.outerGapPct,
268
+ innerGapPct: row.innerGapPct,
269
+ previousCandleStart:
270
+ row.previousCandleStartMs == null
271
+ ? ""
272
+ : new Date(row.previousCandleStartMs).toISOString(),
273
+ previousCandleEnd:
274
+ row.previousCandleEndMs == null
275
+ ? ""
276
+ : new Date(row.previousCandleEndMs).toISOString(),
277
+ previousCandleRangePct: row.previousCandleRangePct,
278
+ networkFeeSol: row.networkFeeSol,
279
+ degradationBps: row.degradationBps,
280
+ eventIndex: row.eventIndex,
281
+ eventId: row.eventId,
282
+ signature: row.signature,
283
+ source: row.source,
284
+ confidence: row.confidence,
285
+ reason: row.reason,
286
+ }));
287
+ if (!rows.length) return "";
288
+ const headers = Object.keys(rows[0]!);
289
+ return (
290
+ [
291
+ headers.map(csvCell).join(","),
292
+ ...rows.map((row) =>
293
+ headers.map((key) => csvCell(row[key as keyof typeof row])).join(","),
294
+ ),
295
+ ].join("\n") + "\n"
296
+ );
297
+ }
298
+
299
+ const ath = result as TokenAthDipProfitBacktestResult;
300
+ const rows = ath.executions.map((row) => ({
301
+ at: new Date(row.atMs).toISOString(),
302
+ side: row.side,
303
+ status: row.status,
304
+ lotId: row.lotId,
305
+ level: row.level,
306
+ observedPriceSol: row.observedPriceSol,
307
+ executionPriceSol: row.executionPriceSol,
308
+ amountSol: row.amountSol,
309
+ tokens: row.tokens,
310
+ eventIndex: row.eventIndex,
311
+ eventId: row.eventId,
312
+ signature: row.signature,
313
+ source: row.source,
314
+ confidence: row.confidence,
315
+ reason: row.reason,
316
+ }));
317
+ if (!rows.length) return "";
318
+ const headers = Object.keys(rows[0]!);
319
+ return (
320
+ [
321
+ headers.map(csvCell).join(","),
322
+ ...rows.map((row) =>
323
+ headers.map((key) => csvCell(row[key as keyof typeof row])).join(","),
324
+ ),
325
+ ].join("\n") + "\n"
326
+ );
327
+ }
328
+
329
+ function ledgerPath(
330
+ flags: Flags,
331
+ mint: string,
332
+ strategy: CliStrategy,
333
+ ): string | null {
334
+ if (!flags.has("ledger")) return null;
335
+ const requested = flag(flags, "ledger");
336
+ if (requested) return resolve(requested);
337
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
338
+ return resolve(
339
+ "backtest-results",
340
+ `${mint}-${strategy.kind}-${stamp}-ledger.csv`,
341
+ );
342
+ }
343
+
344
+ function executionCostLine(strategy: CliStrategy): string {
345
+ const execution = strategy.execution ?? {};
346
+ const slippageBps = execution.slippageBps ?? 0;
347
+ const venueFeeBps = execution.venueFeeBps ?? 0;
348
+ const networkFeeSol = execution.networkFeeSol ?? 0;
349
+ const latencyMs = execution.latencyMs ?? 0;
350
+ const none =
351
+ slippageBps === 0 &&
352
+ venueFeeBps === 0 &&
353
+ networkFeeSol === 0 &&
354
+ latencyMs === 0;
355
+ return none
356
+ ? "NONE — gross simulation; no slippage, venue fee, network fee, or latency configured"
357
+ : `slippage=${slippageBps}bps/side venue=${venueFeeBps}bps/side network=${networkFeeSol} SOL/tx latency=${latencyMs}ms`;
358
+ }
359
+
360
+ function commonHeader(
361
+ result: CliBacktestResult,
362
+ window: ReplayWindow,
363
+ ): string[] {
364
+ const coverage = result.coverage;
365
+ const historyStart = coverage.provenFromCreation
366
+ ? `FROM CREATION (first-trade gap ${coverage.creationGapMs ?? 0}ms)`
367
+ : coverage.status === "unknown"
368
+ ? "UNKNOWN START"
369
+ : `PARTIAL START (first-trade gap ${coverage.creationGapMs ?? "unknown"}ms)`;
370
+ const completeness = coverage.backfillComplete
371
+ ? "COMPLETE"
372
+ : "INCOMPLETE — gaps/truncation/parser skips remain in durable history";
373
+ return [
374
+ "SLRD BACKTEST",
375
+ `Mint: ${result.mint}`,
376
+ `Strategy: ${result.strategy.name ?? result.strategy.kind}`,
377
+ `Tape: ${result.tape.events} usable events (${result.input.sourceRows} ${result.input.source} rows)`,
378
+ `Replay start: ${window.fromLabel}`,
379
+ `Replay end: ${window.toLabel}`,
380
+ `Actual period: ${result.tape.firstAtMs ? new Date(result.tape.firstAtMs).toISOString() : "n/a"} -> ${result.tape.lastAtMs ? new Date(result.tape.lastAtMs).toISOString() : "n/a"}`,
381
+ `History coverage: ${historyStart}; ${completeness}`,
382
+ `Execution costs: ${executionCostLine(result.strategy)}`,
383
+ `Price sanity: ${result.input.skippedAnomalousPrice > 0 ? `excluded ${result.input.skippedAnomalousPrice} isolated anomalous price event(s)` : "no isolated anomalies detected"}`,
384
+ ];
385
+ }
386
+
387
+ function humanReportAth(
388
+ result: TokenAthDipProfitBacktestResult,
389
+ window: ReplayWindow,
390
+ ): string {
391
+ const s = result.summary;
392
+ return [
393
+ ...commonHeader(result, window),
394
+ "",
395
+ `Starting capital: ${fmtSol(s.startingSol)}`,
396
+ `Ending cash: ${fmtSol(s.endingCashSol)}`,
397
+ `Open liquidation value: ${fmtSol(s.openLiquidationValueSol)}`,
398
+ `Final equity: ${fmtSol(s.finalEquitySol)}`,
399
+ `Net P&L: ${fmtSol(s.netPnlSol)} (${fmtPct(s.returnPct)})`,
400
+ `Realized P&L: ${fmtSol(s.realizedPnlSol)}`,
401
+ `Unrealized P&L: ${fmtSol(s.unrealizedPnlSol)}`,
402
+ `Max drawdown: ${fmtPct(s.maxDrawdownPct)}`,
403
+ `Max deployed: ${fmtSol(s.maxDeployedSol)}`,
404
+ "",
405
+ `Buys / sells: ${s.buys} / ${s.sells}`,
406
+ `Closed / open lots: ${s.closedLots} / ${s.openLots}`,
407
+ `Wins / losses: ${s.winningLots} / ${s.losingLots}`,
408
+ `Win rate: ${fmtPct(s.winRatePct)}`,
409
+ `Skipped entries: ${s.skippedEntries}`,
410
+ `Average hold: ${fmtTime(s.averageHoldMs)}`,
411
+ `Fastest / longest hold: ${fmtTime(s.fastestHoldMs)} / ${fmtTime(s.longestHoldMs)}`,
412
+ ].join("\n");
413
+ }
414
+
415
+ function humanReportTargetWeight(
416
+ result: TokenTargetWeightBacktestResult,
417
+ window: ReplayWindow,
418
+ ): string {
419
+ const s = result.summary;
420
+ const strategy = result.strategy;
421
+ return [
422
+ ...commonHeader(result, window),
423
+ `Cadence: ${result.tape.cadenceMs / 60_000}m`,
424
+ `Controller: target=${strategy.targetWeightPct}% gap=${strategy.gap.mode} outer=${strategy.gap.outerPct}pp inner=${strategy.gap.innerPct}pp minTrade=${strategy.minTradeSol ?? 0} SOL`,
425
+ "",
426
+ `Starting capital: ${fmtSol(s.startingSol)}`,
427
+ `Ending SOL: ${fmtSol(s.endingSol)}`,
428
+ `Ending tokens: ${s.endingTokens}`,
429
+ `Final equity: ${fmtSol(s.finalEquitySol)}`,
430
+ `Net P&L: ${fmtSol(s.netPnlSol)} (${fmtPct(s.returnPct)})`,
431
+ `Max drawdown: ${fmtPct(s.maxDrawdownPct)}`,
432
+ "",
433
+ `Buys / sells: ${s.buys} / ${s.sells}`,
434
+ `Rebalance signals: ${s.rebalanceSignals}`,
435
+ `Skipped executions: ${s.skippedExecutions}`,
436
+ `Decision points: ${s.decisions}`,
437
+ `Outside band: ${fmtPct(s.decisionOutsideBandPct)}`,
438
+ `X weight avg/min/max: ${fmtPct(s.averageWeightPct)} / ${fmtPct(s.minWeightPct)} / ${fmtPct(s.maxWeightPct)}`,
439
+ `Turnover: ${fmtSol(s.totalTurnoverSol)}`,
440
+ `Network fees: ${fmtSol(s.totalNetworkFeesSol)}`,
441
+ `Slippage+venue cost: ${fmtSol(s.totalDegradationCostSol)}`,
442
+ `Total execution cost: ${fmtSol(s.totalExecutionCostSol)}`,
443
+ "",
444
+ `Benchmark 100% SOL: ${fmtPct(s.benchmark100SolReturnPct)}`,
445
+ `Benchmark 100% token: ${fmtPct(s.benchmark100TokenReturnPct)}`,
446
+ `Benchmark initial mix: ${fmtPct(s.benchmarkInitialMixReturnPct)}`,
447
+ `Excess vs initial mix: ${fmtPct(s.excessVsInitialMixPct)}`,
448
+ ].join("\n");
449
+ }
450
+
451
+ export async function runBacktestCommand(args: {
452
+ values: string[];
453
+ flags: Flags;
454
+ emit: Emit;
455
+ }): Promise<void> {
456
+ const mint = args.values[0]?.trim();
457
+ if (!mint) {
458
+ throw new Error(
459
+ "Usage: slrd backtest <mint> --strategy target-weight [options] | --dip-pct ...",
460
+ );
461
+ }
462
+ const strategy = readStrategy(args.flags);
463
+ const window = resolveReplayWindow(mint, args.flags);
464
+ const commonOptions = {
465
+ startingSol: numberFlag(args.flags, "capital-sol", 5),
466
+ fromMs: window.fromMs,
467
+ toMs: window.toMs,
468
+ includeProcessed: !args.flags.has("confirmed-only"),
469
+ coverageToleranceMs: numberFlag(
470
+ args.flags,
471
+ "coverage-tolerance-ms",
472
+ 60_000,
473
+ ),
474
+ requireFromCreation: args.flags.has("require-from-start"),
475
+ source: args.flags.has("exact-trades")
476
+ ? ("trades" as const)
477
+ : ("candles-1s" as const),
478
+ priceSanity: !args.flags.has("no-price-sanity"),
479
+ isolatedPriceSpikeRatio: numberFlag(args.flags, "price-spike-ratio", 1000),
480
+ priceContinuityRatio: numberFlag(args.flags, "price-continuity-ratio", 5),
481
+ priceSanityWindow: numberFlag(args.flags, "price-sanity-window", 20),
482
+ priceSanityWindowMs: numberFlag(
483
+ args.flags,
484
+ "price-sanity-window-ms",
485
+ 300_000,
486
+ ),
487
+ };
488
+
489
+ const result: CliBacktestResult =
490
+ strategy.kind === "target-weight"
491
+ ? backtestTargetWeightToken(mint, strategy, {
492
+ ...commonOptions,
493
+ cadenceMs: parseDurationMs(flag(args.flags, "cadence"), 300_000),
494
+ })
495
+ : backtestTokenTrades(mint, strategy, commonOptions);
496
+
497
+ const out = flag(args.flags, "out");
498
+ if (out) {
499
+ const path = resolve(out);
500
+ mkdirSync(dirname(path), { recursive: true });
501
+ writeFileSync(path, `${json(result)}\n`);
502
+ }
503
+
504
+ const ledger = ledgerPath(args.flags, mint, strategy);
505
+ if (ledger) {
506
+ mkdirSync(dirname(ledger), { recursive: true });
507
+ writeFileSync(ledger, executionLedgerCsv(result));
508
+ }
509
+
510
+ if (args.flags.has("json")) {
511
+ args.emit(`${json(result)}\n`);
512
+ } else {
513
+ args.emit(
514
+ `${strategy.kind === "target-weight" ? humanReportTargetWeight(result as TokenTargetWeightBacktestResult, window) : humanReportAth(result as TokenAthDipProfitBacktestResult, window)}\n`,
515
+ );
516
+ if (out) args.emit(`Full result: ${resolve(out)}\n`);
517
+ if (ledger)
518
+ args.emit(
519
+ `Execution ledger: ${ledger} (${result.executions.length} rows)\n`,
520
+ );
521
+ if (result.input.priceAnomalies.length) {
522
+ const worst = [...result.input.priceAnomalies].sort(
523
+ (a, b) =>
524
+ Math.max(b.ratioToBefore, b.ratioToAfter) -
525
+ Math.max(a.ratioToBefore, a.ratioToAfter),
526
+ )[0]!;
527
+ args.emit(
528
+ `Price anomaly example: ${new Date(worst.atMs).toISOString()} price=${worst.priceSol} ` +
529
+ `neighbors≈${worst.beforeMedianSol}/${worst.afterMedianSol} signature=${worst.signature}\n`,
530
+ );
531
+ }
532
+ }
533
+ }