@solard/cli 0.2.13 → 0.2.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solard/cli",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Command-line interface for the Solard SDK.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,7 +18,7 @@
18
18
  "bun": ">=1.1.0"
19
19
  },
20
20
  "dependencies": {
21
- "@solard/sdk": "0.2.13",
21
+ "@solard/sdk": "0.2.14",
22
22
  "bs58": "^6.0.0",
23
23
  "@solana/web3.js": "^1.98.4"
24
24
  },
@@ -0,0 +1,262 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ readdirSync,
5
+ statSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { basename, extname, join, resolve } from "node:path";
9
+ import {
10
+ runHistoricalStrategyBatch,
11
+ type AthDipProfitStrategy,
12
+ type HistoricalResearchBatchResult,
13
+ type HistoricalResearchStrategy,
14
+ type HistoricalResearchToken,
15
+ } from "@solard/sdk";
16
+
17
+ type Flags = Map<string, string>;
18
+ type Emit = (value: string) => void;
19
+
20
+ function flag(flags: Flags, key: string): string | undefined {
21
+ const value = flags.get(key);
22
+ return value && value !== "true" ? value : undefined;
23
+ }
24
+
25
+ function numberFlag(
26
+ flags: Flags,
27
+ key: string,
28
+ fallback?: number,
29
+ ): number | undefined {
30
+ const value = flag(flags, key);
31
+ if (value == null) return fallback;
32
+ const parsed = Number(value);
33
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
34
+ return parsed;
35
+ }
36
+
37
+ function slug(value: string): string {
38
+ return (
39
+ value
40
+ .trim()
41
+ .replace(/\.json$/i, "")
42
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
43
+ .replace(/^-+|-+$/g, "") || "strategy"
44
+ );
45
+ }
46
+
47
+ function readTokens(pathInput: string): HistoricalResearchToken[] {
48
+ const path = resolve(pathInput);
49
+ const raw = readFileSync(path, "utf8");
50
+ if (extname(path).toLowerCase() === ".json") {
51
+ const parsed = JSON.parse(raw) as unknown;
52
+ const values = Array.isArray(parsed)
53
+ ? parsed
54
+ : (parsed as { tokens?: unknown })?.tokens;
55
+ if (!Array.isArray(values))
56
+ throw new Error(
57
+ `Token JSON must be an array or {"tokens":[...]}: ${path}`,
58
+ );
59
+ return values.map((row) => {
60
+ if (typeof row === "string") return { mint: row };
61
+ if (!row || typeof row !== "object")
62
+ throw new Error(`Invalid token row in ${path}`);
63
+ const value = row as { mint?: unknown; label?: unknown; tags?: unknown };
64
+ if (typeof value.mint !== "string")
65
+ throw new Error(`Token object is missing string mint in ${path}`);
66
+ return {
67
+ mint: value.mint,
68
+ label: typeof value.label === "string" ? value.label : null,
69
+ tags: Array.isArray(value.tags) ? value.tags.map(String) : [],
70
+ };
71
+ });
72
+ }
73
+
74
+ return raw
75
+ .split(/\r?\n/)
76
+ .map((line) => line.replace(/\s+#.*$/, "").trim())
77
+ .filter((line) => line && !line.startsWith("#"))
78
+ .map((line) => {
79
+ const [mint, label] = line.split(",", 2).map((part) => part.trim());
80
+ if (!mint || mint.toLowerCase() === "mint") return null;
81
+ return { mint, label: label || null } satisfies HistoricalResearchToken;
82
+ })
83
+ .filter((row): row is HistoricalResearchToken => row != null);
84
+ }
85
+
86
+ function readStrategyFile(pathInput: string): HistoricalResearchStrategy {
87
+ const path = resolve(pathInput);
88
+ const strategy = JSON.parse(
89
+ readFileSync(path, "utf8"),
90
+ ) as AthDipProfitStrategy;
91
+ return {
92
+ id: slug(basename(path)),
93
+ strategy,
94
+ source: path,
95
+ };
96
+ }
97
+
98
+ function readStrategies(input: string): HistoricalResearchStrategy[] {
99
+ const direct = resolve(input);
100
+ try {
101
+ if (statSync(direct).isDirectory()) {
102
+ return readdirSync(direct)
103
+ .filter((name) => name.toLowerCase().endsWith(".json"))
104
+ .sort()
105
+ .map((name) => readStrategyFile(join(direct, name)));
106
+ }
107
+ return [readStrategyFile(direct)];
108
+ } catch (error) {
109
+ if (!input.includes(",")) throw error;
110
+ return input
111
+ .split(",")
112
+ .map((value) => value.trim())
113
+ .filter(Boolean)
114
+ .map(readStrategyFile);
115
+ }
116
+ }
117
+
118
+ function csvCell(value: unknown): string {
119
+ const text = value == null ? "" : String(value);
120
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
121
+ }
122
+
123
+ function csv(rows: Record<string, unknown>[]): string {
124
+ if (!rows.length) return "";
125
+ const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
126
+ return (
127
+ [
128
+ headers.map(csvCell).join(","),
129
+ ...rows.map((row) => headers.map((key) => csvCell(row[key])).join(",")),
130
+ ].join("\n") + "\n"
131
+ );
132
+ }
133
+
134
+ function writeResearchOutput(
135
+ dirInput: string,
136
+ result: HistoricalResearchBatchResult,
137
+ ): string {
138
+ const dir = resolve(dirInput);
139
+ mkdirSync(dir, { recursive: true });
140
+ writeFileSync(
141
+ join(dir, "summary.json"),
142
+ `${JSON.stringify(result, null, 2)}\n`,
143
+ );
144
+ writeFileSync(
145
+ join(dir, "strategy-summary.csv"),
146
+ csv(result.strategies as unknown as Record<string, unknown>[]),
147
+ );
148
+ writeFileSync(
149
+ join(dir, "runs.csv"),
150
+ csv(
151
+ result.runs.map((row) => ({
152
+ mint: row.mint,
153
+ label: row.label,
154
+ tags: row.tags.join("|"),
155
+ strategyId: row.strategyId,
156
+ strategyName: row.strategyName,
157
+ coverage: row.coverage.status,
158
+ sourceRows: row.sourceRows,
159
+ usableRows: row.usableRows,
160
+ periodDays: row.periodDays,
161
+ priceHoldReturnPct: row.priceHoldReturnPct,
162
+ returnPct: row.summary.returnPct,
163
+ excessVsHoldPct: row.excessVsHoldPct,
164
+ maxDrawdownPct: row.summary.maxDrawdownPct,
165
+ finalEquitySol: row.summary.finalEquitySol,
166
+ netPnlSol: row.summary.netPnlSol,
167
+ buys: row.summary.buys,
168
+ sells: row.summary.sells,
169
+ closedLots: row.summary.closedLots,
170
+ openLots: row.summary.openLots,
171
+ winRatePct: row.summary.winRatePct,
172
+ })),
173
+ ),
174
+ );
175
+ writeFileSync(
176
+ join(dir, "skipped-tokens.csv"),
177
+ csv(
178
+ result.skippedTokens.map((row) => ({
179
+ mint: row.mint,
180
+ label: row.label,
181
+ reason: row.reason,
182
+ coverage: row.coverage?.status ?? "unknown",
183
+ sourceRows: row.sourceRows,
184
+ usableRows: row.usableRows,
185
+ periodDays: row.periodDays,
186
+ })),
187
+ ),
188
+ );
189
+ writeFileSync(
190
+ join(dir, "failures.csv"),
191
+ csv(result.failures as unknown as Record<string, unknown>[]),
192
+ );
193
+ return dir;
194
+ }
195
+
196
+ function pct(value: number): string {
197
+ return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
198
+ }
199
+
200
+ function report(result: HistoricalResearchBatchResult): string {
201
+ const lines = [
202
+ "SLRD HISTORICAL STRATEGY RESEARCH",
203
+ `Tokens: ${result.input.tokens} input / ${result.eligibleTokens} eligible / ${result.skippedTokens.length} skipped`,
204
+ `Strategies: ${result.input.strategies}`,
205
+ `Runs: ${result.runs.length} completed / ${result.failures.length} failed`,
206
+ `Capital: ${result.options.startingSol} SOL independently per token`,
207
+ `Filters: minEvents=${result.options.minEvents} minDays=${result.options.minDays} requireFromStart=${result.options.requireFromCreation}`,
208
+ "",
209
+ "RANK STRATEGY MEDIAN MEAN PROFITABLE VS HOLD MEDIAN DD TOKENS",
210
+ ];
211
+ for (const row of result.strategies.slice(0, 20)) {
212
+ lines.push(
213
+ `${String(row.rank).padStart(4)} ${row.strategyId.slice(0, 30).padEnd(30)} ` +
214
+ `${pct(row.medianReturnPct).padStart(9)} ${pct(row.meanReturnPct).padStart(9)} ` +
215
+ `${row.profitableTokenPct.toFixed(0).padStart(9)}% ${row.outperformHoldPct.toFixed(0).padStart(7)}% ` +
216
+ `${row.medianMaxDrawdownPct.toFixed(2).padStart(8)}% ${String(row.completedTokens).padStart(6)}`,
217
+ );
218
+ }
219
+ lines.push(
220
+ "",
221
+ "Ranking is in-sample descriptive research, not a claim that rank #1 will perform best live.",
222
+ );
223
+ return lines.join("\n");
224
+ }
225
+
226
+ export async function runBacktestBatchCommand(args: {
227
+ values: string[];
228
+ flags: Flags;
229
+ emit: Emit;
230
+ }): Promise<void> {
231
+ const tokensPath = flag(args.flags, "tokens") ?? args.values[0];
232
+ const strategiesPath = flag(args.flags, "strategies");
233
+ if (!tokensPath || !strategiesPath) {
234
+ throw new Error(
235
+ "Usage: slrd backtest batch --tokens <tokens.txt|json> --strategies <dir|file.json[,file2.json]> " +
236
+ "[--capital-sol 5] [--min-events 100] [--min-days 1] [--require-from-start] [--out <dir>]",
237
+ );
238
+ }
239
+
240
+ const tokens = readTokens(tokensPath);
241
+ const strategies = readStrategies(strategiesPath);
242
+ const result = runHistoricalStrategyBatch(tokens, strategies, {
243
+ startingSol: numberFlag(args.flags, "capital-sol", 5),
244
+ includeProcessed: !args.flags.has("confirmed-only"),
245
+ coverageToleranceMs: numberFlag(
246
+ args.flags,
247
+ "coverage-tolerance-ms",
248
+ 60_000,
249
+ ),
250
+ requireFromCreation: args.flags.has("require-from-start"),
251
+ minEvents: numberFlag(args.flags, "min-events", 2),
252
+ minDays: numberFlag(args.flags, "min-days", 0),
253
+ });
254
+
255
+ const out = flag(args.flags, "out");
256
+ const written = out ? writeResearchOutput(out, result) : null;
257
+ if (args.flags.has("json")) args.emit(`${JSON.stringify(result, null, 2)}\n`);
258
+ else {
259
+ args.emit(`${report(result)}\n`);
260
+ if (written) args.emit(`Research output: ${written}\n`);
261
+ }
262
+ }