@solard/cli 0.2.17 → 0.2.20
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 +2 -2
- package/src/index.ts +788 -208
- package/src/launch-watch-command.ts +278 -0
- package/src/price-feed-client.ts +2 -0
- package/src/price-feed-protocol.ts +8 -0
- package/src/price-feed-server.ts +1595 -0
- package/src/pump-watch-command.ts +11 -0
- package/src/reclaim-command.ts +335 -0
- package/src/research-history-cache.ts +331 -0
- package/src/strategy-backtest-command.ts +740 -0
- package/src/token-history-command.ts +183 -30
- package/src/trade-strategy-command.ts +1247 -0
- package/src/tx-stream.test.ts +38 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { Connection, type ParsedTransactionWithMeta } from "@solana/web3.js";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
getTokenHistoryCoverage,
|
|
6
|
+
loadTokenHistoryTrades,
|
|
7
|
+
parsePumpHistoryTransaction,
|
|
8
|
+
type TokenHistoryTrade,
|
|
9
|
+
} from "@solard/core";
|
|
10
|
+
import {
|
|
11
|
+
SolanaTokenHistoryRpc,
|
|
12
|
+
normalizeTokenHistoryRpcOptions,
|
|
13
|
+
} from "@solard/core/chain/token-history.ts";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
cacheResearchTransactions,
|
|
17
|
+
cachedResearchTransactions,
|
|
18
|
+
loadResearchHistoryIndex,
|
|
19
|
+
researchHistoryCachePath,
|
|
20
|
+
researchHistoryMeta,
|
|
21
|
+
type ResearchHistorySignature,
|
|
22
|
+
} from "./research-history-cache.ts";
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
StrategyExecution,
|
|
26
|
+
StrategyPosition,
|
|
27
|
+
StrategyPriceTick,
|
|
28
|
+
StrategyTrade,
|
|
29
|
+
TradeStrategy,
|
|
30
|
+
TradeStrategyContext,
|
|
31
|
+
} from "./trade-strategy-command.ts";
|
|
32
|
+
|
|
33
|
+
type Flags = Map<string, string>;
|
|
34
|
+
type Emit = (value: string) => void;
|
|
35
|
+
|
|
36
|
+
type ReplayEvent = {
|
|
37
|
+
trade: StrategyTrade;
|
|
38
|
+
price: StrategyPriceTick;
|
|
39
|
+
priceSol: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type BacktestExecution = StrategyExecution & {
|
|
43
|
+
signalAtMs: number;
|
|
44
|
+
fillAtMs: number | null;
|
|
45
|
+
fillPriceSol: number | null;
|
|
46
|
+
cashAfterSol: number;
|
|
47
|
+
tokenAfterUi: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type BacktestNotice = {
|
|
51
|
+
atMs: number;
|
|
52
|
+
kind: "notify" | "log";
|
|
53
|
+
message: string;
|
|
54
|
+
data: unknown;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function flag(flags: Flags, key: string): string | undefined {
|
|
58
|
+
const value = flags.get(key);
|
|
59
|
+
return value && value !== "true" ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function numberFlag(flags: Flags, key: string, fallback: number): number {
|
|
63
|
+
const value = flag(flags, key);
|
|
64
|
+
if (value == null) return fallback;
|
|
65
|
+
const parsed = Number(value);
|
|
66
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function durationMs(value: string | undefined, fallback: number): number {
|
|
71
|
+
if (!value) return fallback;
|
|
72
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value.trim());
|
|
73
|
+
if (!match) throw new Error(`Invalid duration: ${value}`);
|
|
74
|
+
const scale = ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 } as const)[
|
|
75
|
+
(match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h"
|
|
76
|
+
];
|
|
77
|
+
return Math.max(1, Math.trunc(Number(match[1]) * scale));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseTime(value: string | undefined): number | undefined {
|
|
81
|
+
if (!value) return undefined;
|
|
82
|
+
if (/^\d+$/.test(value)) {
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
return parsed < 10_000_000_000 ? parsed * 1_000 : parsed;
|
|
85
|
+
}
|
|
86
|
+
const parsed = Date.parse(value);
|
|
87
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid time: ${value}`);
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function params(flags: Flags): Record<string, unknown> {
|
|
92
|
+
const raw = flag(flags, "params");
|
|
93
|
+
if (!raw) return {};
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
96
|
+
throw new Error("--params must be a JSON object");
|
|
97
|
+
return parsed as Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadStrategy(path: string): Promise<TradeStrategy<any>> {
|
|
101
|
+
const absolute = resolve(path);
|
|
102
|
+
const module = await import(
|
|
103
|
+
`${pathToFileURL(absolute).href}?backtest=${Date.now()}`
|
|
104
|
+
);
|
|
105
|
+
const strategy = (module.default ?? module.strategy) as
|
|
106
|
+
TradeStrategy<any> | undefined;
|
|
107
|
+
if (!strategy || typeof strategy !== "object")
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Strategy ${absolute} must default-export a strategy object`,
|
|
110
|
+
);
|
|
111
|
+
if (
|
|
112
|
+
typeof strategy.onPrice !== "function" &&
|
|
113
|
+
typeof strategy.onTrade !== "function"
|
|
114
|
+
)
|
|
115
|
+
throw new Error(`Strategy ${absolute} must define onPrice() or onTrade()`);
|
|
116
|
+
return strategy;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function asEvent(row: TokenHistoryTrade): ReplayEvent | null {
|
|
120
|
+
const priceSol = Number(row.priceSol);
|
|
121
|
+
if (!(priceSol > 0) || !Number.isFinite(priceSol)) return null;
|
|
122
|
+
const tokenAmountUi = Math.abs(Number(row.tokenDeltaUi));
|
|
123
|
+
const quoteAmountUi = Math.abs(Number(row.solDeltaUi));
|
|
124
|
+
const trade: StrategyTrade = {
|
|
125
|
+
id: row.eventKey,
|
|
126
|
+
signature: row.signature,
|
|
127
|
+
slot: row.slot,
|
|
128
|
+
atMs: row.tradedAtMs,
|
|
129
|
+
mint: row.mint,
|
|
130
|
+
venue: row.history?.venue ?? row.source,
|
|
131
|
+
side: row.side,
|
|
132
|
+
trader: row.owner ?? "",
|
|
133
|
+
isMine: false,
|
|
134
|
+
tokenAmountRaw: 0n,
|
|
135
|
+
tokenAmountUi,
|
|
136
|
+
quote: "SOL",
|
|
137
|
+
quoteAmountUi,
|
|
138
|
+
priceSol,
|
|
139
|
+
priceUsd: row.priceUsd,
|
|
140
|
+
marketCapUsd: row.marketCapUsd,
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
trade,
|
|
144
|
+
price: {
|
|
145
|
+
signature: row.signature,
|
|
146
|
+
slot: row.slot,
|
|
147
|
+
atMs: row.tradedAtMs,
|
|
148
|
+
mint: row.mint,
|
|
149
|
+
priceSol,
|
|
150
|
+
priceUsd: row.priceUsd,
|
|
151
|
+
marketCapUsd: row.marketCapUsd,
|
|
152
|
+
},
|
|
153
|
+
priceSol,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function coalescePriceEvents(
|
|
158
|
+
events: ReplayEvent[],
|
|
159
|
+
sampleMs: number,
|
|
160
|
+
): ReplayEvent[] {
|
|
161
|
+
const buckets = new Map<number, ReplayEvent>();
|
|
162
|
+
for (const event of events) {
|
|
163
|
+
const bucket = Math.floor(event.price.atMs / sampleMs) * sampleMs;
|
|
164
|
+
const existing = buckets.get(bucket);
|
|
165
|
+
if (!existing || event.price.atMs >= existing.price.atMs)
|
|
166
|
+
buckets.set(bucket, event);
|
|
167
|
+
}
|
|
168
|
+
return [...buckets.values()].sort(
|
|
169
|
+
(a, b) => a.price.atMs - b.price.atMs || a.price.slot - b.price.slot,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rpcEndpoint(flags: Flags): string {
|
|
174
|
+
const value =
|
|
175
|
+
flag(flags, "rpc") ??
|
|
176
|
+
process.env.RPC_ENDPOINT?.trim() ??
|
|
177
|
+
process.env.SOLANA_RPC_URL?.trim() ??
|
|
178
|
+
process.env.HELIUS_RPC_URL?.trim();
|
|
179
|
+
if (!value)
|
|
180
|
+
throw new Error(
|
|
181
|
+
"Lazy strategy backtest requires RPC_ENDPOINT, SOLANA_RPC_URL, HELIUS_RPC_URL, or --rpc <url>",
|
|
182
|
+
);
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function asRpcRows(rows: readonly ResearchHistorySignature[]) {
|
|
187
|
+
return rows.map((row) => ({
|
|
188
|
+
signature: row.signature,
|
|
189
|
+
slot: row.slot,
|
|
190
|
+
blockTime: row.blockTime,
|
|
191
|
+
err: null,
|
|
192
|
+
memo: null,
|
|
193
|
+
confirmationStatus: "finalized" as const,
|
|
194
|
+
scanKind: row.scanKind,
|
|
195
|
+
scanAddress: row.scanAddress,
|
|
196
|
+
localChronologicalOrder: row.localChronologicalOrder,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function hydrateResearchTransactions(args: {
|
|
201
|
+
rows: readonly ResearchHistorySignature[];
|
|
202
|
+
flags: Flags;
|
|
203
|
+
}): Promise<Map<string, ParsedTransactionWithMeta>> {
|
|
204
|
+
const signatures = args.rows.map((row) => row.signature);
|
|
205
|
+
const cached = cachedResearchTransactions(signatures);
|
|
206
|
+
const missing = args.rows.filter((row) => !cached.has(row.signature));
|
|
207
|
+
if (!missing.length) return cached;
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`CACHE hit=${cached.size} miss=${missing.length} path=${researchHistoryCachePath()}\n`,
|
|
210
|
+
);
|
|
211
|
+
const connection = new Connection(rpcEndpoint(args.flags), "confirmed");
|
|
212
|
+
const rpc = new SolanaTokenHistoryRpc(connection);
|
|
213
|
+
const fetched = await rpc.fetchTransactions(
|
|
214
|
+
asRpcRows(missing),
|
|
215
|
+
normalizeTokenHistoryRpcOptions({
|
|
216
|
+
commitment: args.flags.has("confirmed") ? "confirmed" : "finalized",
|
|
217
|
+
transactionBatchSize: Math.max(
|
|
218
|
+
1,
|
|
219
|
+
Math.min(100, Math.trunc(numberFlag(args.flags, "batch-size", 100))),
|
|
220
|
+
),
|
|
221
|
+
transactionConcurrency: Math.max(
|
|
222
|
+
1,
|
|
223
|
+
Math.min(5, Math.trunc(numberFlag(args.flags, "rpc-concurrency", 2))),
|
|
224
|
+
),
|
|
225
|
+
rpcTimeoutMs: Math.max(
|
|
226
|
+
1_000,
|
|
227
|
+
Math.trunc(numberFlag(args.flags, "rpc-timeout-ms", 20_000)),
|
|
228
|
+
),
|
|
229
|
+
rpcRetries: Math.max(
|
|
230
|
+
0,
|
|
231
|
+
Math.trunc(numberFlag(args.flags, "rpc-retries", 2)),
|
|
232
|
+
),
|
|
233
|
+
retryDelayMs: Math.max(
|
|
234
|
+
100,
|
|
235
|
+
Math.trunc(numberFlag(args.flags, "retry-delay-ms", 750)),
|
|
236
|
+
),
|
|
237
|
+
onProgress: (row) => {
|
|
238
|
+
if (
|
|
239
|
+
row.phase === "transactions" &&
|
|
240
|
+
(row.completed === row.total || row.completed % 500 === 0)
|
|
241
|
+
)
|
|
242
|
+
process.stderr.write(`TX ${row.completed}/${row.total}\n`);
|
|
243
|
+
if (row.phase === "retry")
|
|
244
|
+
process.stderr.write(
|
|
245
|
+
`RETRY ${row.attempt}/${row.maxAttempts} ${row.error}\n`,
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
cacheResearchTransactions(
|
|
251
|
+
fetched.bySignature,
|
|
252
|
+
args.flags.has("confirmed") ? "confirmed" : "finalized",
|
|
253
|
+
);
|
|
254
|
+
for (const [signature, tx] of fetched.bySignature) cached.set(signature, tx);
|
|
255
|
+
return cached;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function applyHistoricalUsd(
|
|
259
|
+
row: TokenHistoryTrade,
|
|
260
|
+
solUsd: number | null,
|
|
261
|
+
): TokenHistoryTrade {
|
|
262
|
+
if (!(solUsd != null && solUsd > 0)) return row;
|
|
263
|
+
const priceSol = Number(row.priceSol);
|
|
264
|
+
const marketCapSol = Number(row.history?.marketCapSol);
|
|
265
|
+
return {
|
|
266
|
+
...row,
|
|
267
|
+
priceUsd:
|
|
268
|
+
Number.isFinite(priceSol) && priceSol > 0 ? priceSol * solUsd : null,
|
|
269
|
+
marketCapUsd:
|
|
270
|
+
Number.isFinite(marketCapSol) && marketCapSol > 0
|
|
271
|
+
? marketCapSol * solUsd
|
|
272
|
+
: null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseResearchTransaction(args: {
|
|
277
|
+
mint: string;
|
|
278
|
+
row: ResearchHistorySignature;
|
|
279
|
+
tx: ParsedTransactionWithMeta;
|
|
280
|
+
decimals: number;
|
|
281
|
+
supplyUi: number;
|
|
282
|
+
solUsd: number | null;
|
|
283
|
+
}): TokenHistoryTrade[] {
|
|
284
|
+
const result = parsePumpHistoryTransaction({
|
|
285
|
+
tx: args.tx,
|
|
286
|
+
signature: args.row.signature,
|
|
287
|
+
mint: args.mint,
|
|
288
|
+
decimals: args.decimals,
|
|
289
|
+
supplyUi: args.supplyUi,
|
|
290
|
+
historyOrder: args.row.localChronologicalOrder,
|
|
291
|
+
scanAddress: args.row.scanAddress,
|
|
292
|
+
scanKind: args.row.scanKind,
|
|
293
|
+
confidence: "finalized",
|
|
294
|
+
updatedAtMs:
|
|
295
|
+
args.tx.blockTime == null ? Date.now() : args.tx.blockTime * 1_000,
|
|
296
|
+
});
|
|
297
|
+
return result.trades.map((row) => applyHistoricalUsd(row, args.solUsd));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function lazyResearchTrades(args: {
|
|
301
|
+
mint: string;
|
|
302
|
+
strategy: TradeStrategy<any>;
|
|
303
|
+
flags: Flags;
|
|
304
|
+
fromMs?: number;
|
|
305
|
+
toMs?: number;
|
|
306
|
+
}): Promise<{
|
|
307
|
+
rows: TokenHistoryTrade[];
|
|
308
|
+
indexed: number;
|
|
309
|
+
requested: number;
|
|
310
|
+
cachePath: string;
|
|
311
|
+
}> {
|
|
312
|
+
const meta = researchHistoryMeta(args.mint);
|
|
313
|
+
if (!meta)
|
|
314
|
+
throw new Error(
|
|
315
|
+
`No research signature index for ${args.mint}. Run: slrd token backfill ${args.mint}`,
|
|
316
|
+
);
|
|
317
|
+
const indexed = loadResearchHistoryIndex(args.mint, {
|
|
318
|
+
fromMs: args.fromMs,
|
|
319
|
+
toMs: args.toMs,
|
|
320
|
+
});
|
|
321
|
+
if (!indexed.length)
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Research index contains no signatures for requested window`,
|
|
324
|
+
);
|
|
325
|
+
const exact =
|
|
326
|
+
args.strategy.history?.mode === "exact" || Boolean(args.strategy.onTrade);
|
|
327
|
+
const sampleMs = exact
|
|
328
|
+
? 0
|
|
329
|
+
: durationMs(
|
|
330
|
+
flag(args.flags, "price-sample"),
|
|
331
|
+
Math.max(1_000, Math.trunc(args.strategy.history?.sampleMs ?? 1_000)),
|
|
332
|
+
);
|
|
333
|
+
const solUsd =
|
|
334
|
+
flag(args.flags, "sol-usd") == null
|
|
335
|
+
? null
|
|
336
|
+
: numberFlag(args.flags, "sol-usd", 0);
|
|
337
|
+
|
|
338
|
+
if (exact) {
|
|
339
|
+
const txs = await hydrateResearchTransactions({
|
|
340
|
+
rows: indexed,
|
|
341
|
+
flags: args.flags,
|
|
342
|
+
});
|
|
343
|
+
const rows: TokenHistoryTrade[] = [];
|
|
344
|
+
for (const row of indexed) {
|
|
345
|
+
const tx = txs.get(row.signature);
|
|
346
|
+
if (!tx) continue;
|
|
347
|
+
rows.push(
|
|
348
|
+
...parseResearchTransaction({
|
|
349
|
+
...args,
|
|
350
|
+
row,
|
|
351
|
+
tx,
|
|
352
|
+
decimals: meta.decimals,
|
|
353
|
+
supplyUi: meta.supplyUi,
|
|
354
|
+
solUsd,
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
rows,
|
|
360
|
+
indexed: indexed.length,
|
|
361
|
+
requested: indexed.length,
|
|
362
|
+
cachePath: researchHistoryCachePath(),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const buckets = new Map<number, ResearchHistorySignature[]>();
|
|
367
|
+
for (const row of indexed) {
|
|
368
|
+
if (row.blockTime == null) continue;
|
|
369
|
+
const bucket = Math.floor((row.blockTime * 1_000) / sampleMs);
|
|
370
|
+
const list = buckets.get(bucket) ?? [];
|
|
371
|
+
list.push(row);
|
|
372
|
+
buckets.set(bucket, list);
|
|
373
|
+
}
|
|
374
|
+
for (const list of buckets.values())
|
|
375
|
+
list.sort(
|
|
376
|
+
(a, b) =>
|
|
377
|
+
b.slot - a.slot ||
|
|
378
|
+
b.localChronologicalOrder - a.localChronologicalOrder ||
|
|
379
|
+
b.signature.localeCompare(a.signature),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const unresolved = new Map(buckets);
|
|
383
|
+
const chosen: TokenHistoryTrade[] = [];
|
|
384
|
+
let requested = 0;
|
|
385
|
+
let depth = 0;
|
|
386
|
+
while (unresolved.size) {
|
|
387
|
+
const candidates: ResearchHistorySignature[] = [];
|
|
388
|
+
for (const list of unresolved.values()) {
|
|
389
|
+
const row = list[depth];
|
|
390
|
+
if (row) candidates.push(row);
|
|
391
|
+
}
|
|
392
|
+
if (!candidates.length) break;
|
|
393
|
+
requested += candidates.length;
|
|
394
|
+
const txs = await hydrateResearchTransactions({
|
|
395
|
+
rows: candidates,
|
|
396
|
+
flags: args.flags,
|
|
397
|
+
});
|
|
398
|
+
for (const [bucket, list] of [...unresolved]) {
|
|
399
|
+
const row = list[depth];
|
|
400
|
+
if (!row) {
|
|
401
|
+
unresolved.delete(bucket);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const tx = txs.get(row.signature);
|
|
405
|
+
if (!tx) continue;
|
|
406
|
+
const parsed = parseResearchTransaction({
|
|
407
|
+
mint: args.mint,
|
|
408
|
+
row,
|
|
409
|
+
tx,
|
|
410
|
+
decimals: meta.decimals,
|
|
411
|
+
supplyUi: meta.supplyUi,
|
|
412
|
+
solUsd,
|
|
413
|
+
}).filter((trade) => Number(trade.priceSol) > 0);
|
|
414
|
+
if (parsed.length) {
|
|
415
|
+
chosen.push(parsed.at(-1)!);
|
|
416
|
+
unresolved.delete(bucket);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
depth += 1;
|
|
420
|
+
}
|
|
421
|
+
chosen.sort((a, b) => a.tradedAtMs - b.tradedAtMs || a.slot - b.slot);
|
|
422
|
+
process.stderr.write(
|
|
423
|
+
`LAZY sample=${sampleMs}ms buckets=${buckets.size} hydrated=${requested} prices=${chosen.length}\n`,
|
|
424
|
+
);
|
|
425
|
+
return {
|
|
426
|
+
rows: chosen,
|
|
427
|
+
indexed: indexed.length,
|
|
428
|
+
requested,
|
|
429
|
+
cachePath: researchHistoryCachePath(),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function cloneState<State>(value: State): State {
|
|
434
|
+
if (typeof structuredClone === "function") return structuredClone(value);
|
|
435
|
+
return JSON.parse(JSON.stringify(value)) as State;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function fmtPct(value: number): string {
|
|
439
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function runStrategyBacktestCommand(args: {
|
|
443
|
+
values: string[];
|
|
444
|
+
flags: Flags;
|
|
445
|
+
emit: Emit;
|
|
446
|
+
}): Promise<void> {
|
|
447
|
+
const file = args.values[1];
|
|
448
|
+
if (!file)
|
|
449
|
+
throw new Error(
|
|
450
|
+
"Usage: slrd strategy backtest <file.ts> --token <mint> [--params <json>] [--capital-sol 1] [--price-sample 500ms] [--json]",
|
|
451
|
+
);
|
|
452
|
+
const mint = flag(args.flags, "token") ?? flag(args.flags, "mint");
|
|
453
|
+
if (!mint) throw new Error("strategy backtest requires --token <mint>");
|
|
454
|
+
|
|
455
|
+
const strategy = await loadStrategy(file);
|
|
456
|
+
const coverage = getTokenHistoryCoverage(mint);
|
|
457
|
+
const indexedMeta = researchHistoryMeta(mint);
|
|
458
|
+
const fromMs = parseTime(flag(args.flags, "from"));
|
|
459
|
+
const toMs = parseTime(flag(args.flags, "to"));
|
|
460
|
+
if (
|
|
461
|
+
args.flags.has("require-from-start") &&
|
|
462
|
+
!(indexedMeta?.fromCreation || coverage?.complete)
|
|
463
|
+
)
|
|
464
|
+
throw new Error(
|
|
465
|
+
`History for ${mint} is not indexed from creation; run token backfill first`,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const lazy = indexedMeta
|
|
469
|
+
? await lazyResearchTrades({
|
|
470
|
+
mint,
|
|
471
|
+
strategy,
|
|
472
|
+
flags: args.flags,
|
|
473
|
+
fromMs,
|
|
474
|
+
toMs,
|
|
475
|
+
})
|
|
476
|
+
: null;
|
|
477
|
+
const rawRows = (lazy?.rows ?? loadTokenHistoryTrades(mint))
|
|
478
|
+
.filter((row) => row.confidence !== "dropped")
|
|
479
|
+
.filter((row) => fromMs == null || row.tradedAtMs >= fromMs)
|
|
480
|
+
.filter((row) => toMs == null || row.tradedAtMs <= toMs)
|
|
481
|
+
.sort((a, b) => a.tradedAtMs - b.tradedAtMs || a.slot - b.slot);
|
|
482
|
+
const exact = rawRows
|
|
483
|
+
.map(asEvent)
|
|
484
|
+
.filter((row): row is ReplayEvent => row != null);
|
|
485
|
+
if (exact.length < 2)
|
|
486
|
+
throw new Error(
|
|
487
|
+
`Need at least two usable historical trades for ${mint}; run: slrd token backfill ${mint}`,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const priceSampleMs = durationMs(
|
|
491
|
+
flag(args.flags, "price-sample"),
|
|
492
|
+
Math.max(1_000, Math.trunc(strategy.history?.sampleMs ?? 1_000)),
|
|
493
|
+
);
|
|
494
|
+
const replay = strategy.onTrade
|
|
495
|
+
? exact
|
|
496
|
+
: coalescePriceEvents(exact, priceSampleMs);
|
|
497
|
+
const inputParams = params(args.flags);
|
|
498
|
+
const initialState =
|
|
499
|
+
typeof strategy.state === "function"
|
|
500
|
+
? await strategy.state()
|
|
501
|
+
: strategy.state == null
|
|
502
|
+
? {}
|
|
503
|
+
: cloneState(strategy.state);
|
|
504
|
+
|
|
505
|
+
const startingSol = numberFlag(args.flags, "capital-sol", 1);
|
|
506
|
+
const slippageBps = Math.max(0, numberFlag(args.flags, "slippage-bps", 500));
|
|
507
|
+
const venueFeeBps = Math.max(0, numberFlag(args.flags, "venue-fee-bps", 0));
|
|
508
|
+
const networkFeeSol = Math.max(
|
|
509
|
+
0,
|
|
510
|
+
numberFlag(args.flags, "network-fee-sol", 0.00001),
|
|
511
|
+
);
|
|
512
|
+
let cashSol = startingSol;
|
|
513
|
+
let tokenUi = 0;
|
|
514
|
+
let stopped = false;
|
|
515
|
+
let stopReason = "end-of-history";
|
|
516
|
+
let currentIndex = -1;
|
|
517
|
+
let priceCount = 0;
|
|
518
|
+
let tradeCount = 0;
|
|
519
|
+
let lastPrice: StrategyPriceTick | null = null;
|
|
520
|
+
let lastTrade: StrategyTrade | null = null;
|
|
521
|
+
const executions: BacktestExecution[] = [];
|
|
522
|
+
const notices: BacktestNotice[] = [];
|
|
523
|
+
|
|
524
|
+
const currentEvent = () => replay[Math.max(0, currentIndex)]!;
|
|
525
|
+
const fillEvent = () => replay[currentIndex + 1] ?? null;
|
|
526
|
+
const position = (): StrategyPosition => ({
|
|
527
|
+
tokenRaw:
|
|
528
|
+
tokenUi > 0 ? BigInt(Math.max(1, Math.floor(tokenUi * 1_000_000))) : 0n,
|
|
529
|
+
tokenUi,
|
|
530
|
+
tokenDecimals: null,
|
|
531
|
+
solLamports: BigInt(Math.max(0, Math.floor(cashSol * 1_000_000_000))),
|
|
532
|
+
sol: cashSol,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const execute = async (
|
|
536
|
+
side: "buy" | "sell",
|
|
537
|
+
requested: number,
|
|
538
|
+
reason: string | undefined,
|
|
539
|
+
): Promise<StrategyExecution> => {
|
|
540
|
+
const signal = currentEvent();
|
|
541
|
+
const fill = fillEvent();
|
|
542
|
+
const degradation = (slippageBps + venueFeeBps) / 10_000;
|
|
543
|
+
let signature: string | null = null;
|
|
544
|
+
let fillPriceSol: number | null = null;
|
|
545
|
+
let fillAtMs: number | null = null;
|
|
546
|
+
if (fill) {
|
|
547
|
+
fillPriceSol =
|
|
548
|
+
side === "buy"
|
|
549
|
+
? fill.priceSol * (1 + degradation)
|
|
550
|
+
: fill.priceSol * Math.max(0, 1 - degradation);
|
|
551
|
+
fillAtMs = fill.price.atMs;
|
|
552
|
+
signature = fill.price.signature;
|
|
553
|
+
if (side === "buy") {
|
|
554
|
+
const spend = Math.min(
|
|
555
|
+
Math.max(0, requested),
|
|
556
|
+
Math.max(0, cashSol - networkFeeSol),
|
|
557
|
+
);
|
|
558
|
+
if (spend > 0 && fillPriceSol > 0) {
|
|
559
|
+
cashSol -= spend + networkFeeSol;
|
|
560
|
+
tokenUi += spend / fillPriceSol;
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
const pct = Math.max(0, Math.min(100, requested));
|
|
564
|
+
const sold = tokenUi * (pct / 100);
|
|
565
|
+
if (sold > 0 && fillPriceSol > 0) {
|
|
566
|
+
tokenUi -= sold;
|
|
567
|
+
cashSol += sold * fillPriceSol - networkFeeSol;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const execution: BacktestExecution = {
|
|
572
|
+
live: false,
|
|
573
|
+
side,
|
|
574
|
+
requested,
|
|
575
|
+
reason: reason ?? null,
|
|
576
|
+
venue: fill?.trade.venue ?? null,
|
|
577
|
+
signature,
|
|
578
|
+
tokenDeltaRaw: null,
|
|
579
|
+
solDeltaLamports: null,
|
|
580
|
+
completedAt: fillAtMs ?? signal.price.atMs,
|
|
581
|
+
signalAtMs: signal.price.atMs,
|
|
582
|
+
fillAtMs,
|
|
583
|
+
fillPriceSol,
|
|
584
|
+
cashAfterSol: cashSol,
|
|
585
|
+
tokenAfterUi: tokenUi,
|
|
586
|
+
};
|
|
587
|
+
executions.push(execution);
|
|
588
|
+
return execution;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const ctx: TradeStrategyContext<any> = {
|
|
592
|
+
mint,
|
|
593
|
+
wallet: "backtest",
|
|
594
|
+
walletAddress: "backtest",
|
|
595
|
+
live: false,
|
|
596
|
+
state: initialState,
|
|
597
|
+
params: inputParams,
|
|
598
|
+
get priceCount() {
|
|
599
|
+
return priceCount;
|
|
600
|
+
},
|
|
601
|
+
get lastPrice() {
|
|
602
|
+
return lastPrice;
|
|
603
|
+
},
|
|
604
|
+
get tradeCount() {
|
|
605
|
+
return tradeCount;
|
|
606
|
+
},
|
|
607
|
+
get lastTrade() {
|
|
608
|
+
return lastTrade;
|
|
609
|
+
},
|
|
610
|
+
buy: ({ sol, reason }) => execute("buy", sol, reason),
|
|
611
|
+
sell: ({ percent, reason }) => execute("sell", percent, reason),
|
|
612
|
+
position: async () => position(),
|
|
613
|
+
samplePrice: async () => ({
|
|
614
|
+
price: currentEvent().priceSol,
|
|
615
|
+
venue: currentEvent().trade.venue,
|
|
616
|
+
}),
|
|
617
|
+
log: (message, data) =>
|
|
618
|
+
notices.push({
|
|
619
|
+
atMs: currentEvent()?.price.atMs ?? Date.now(),
|
|
620
|
+
kind: "log",
|
|
621
|
+
message,
|
|
622
|
+
data: data ?? null,
|
|
623
|
+
}),
|
|
624
|
+
notify: (message, data) =>
|
|
625
|
+
notices.push({
|
|
626
|
+
atMs: currentEvent()?.price.atMs ?? Date.now(),
|
|
627
|
+
kind: "notify",
|
|
628
|
+
message,
|
|
629
|
+
data: data ?? null,
|
|
630
|
+
}),
|
|
631
|
+
stop: (reason) => {
|
|
632
|
+
stopped = true;
|
|
633
|
+
stopReason = reason ?? "strategy-stop";
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
if (strategy.onStart) await strategy.onStart(ctx);
|
|
638
|
+
for (
|
|
639
|
+
currentIndex = 0;
|
|
640
|
+
currentIndex < replay.length && !stopped;
|
|
641
|
+
currentIndex += 1
|
|
642
|
+
) {
|
|
643
|
+
const event = replay[currentIndex]!;
|
|
644
|
+
try {
|
|
645
|
+
lastPrice = event.price;
|
|
646
|
+
priceCount += 1;
|
|
647
|
+
if (strategy.onPrice) await strategy.onPrice(ctx, event.price);
|
|
648
|
+
if (strategy.onTrade) {
|
|
649
|
+
lastTrade = event.trade;
|
|
650
|
+
tradeCount += 1;
|
|
651
|
+
await strategy.onTrade(ctx, event.trade);
|
|
652
|
+
}
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (strategy.onError) await strategy.onError(ctx, error, lastTrade);
|
|
655
|
+
else throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (strategy.onStop) await strategy.onStop(ctx, stopReason);
|
|
659
|
+
|
|
660
|
+
const finalPrice = replay.at(-1)!.priceSol;
|
|
661
|
+
const finalEquitySol = cashSol + tokenUi * finalPrice;
|
|
662
|
+
const returnPct =
|
|
663
|
+
startingSol > 0 ? (finalEquitySol / startingSol - 1) * 100 : 0;
|
|
664
|
+
const notifications = notices.filter((row) => row.kind === "notify");
|
|
665
|
+
const result = {
|
|
666
|
+
strategy: strategy.name ?? file,
|
|
667
|
+
mint,
|
|
668
|
+
coverage,
|
|
669
|
+
researchCache: lazy
|
|
670
|
+
? {
|
|
671
|
+
path: lazy.cachePath,
|
|
672
|
+
indexed: lazy.indexed,
|
|
673
|
+
hydratedRequested: lazy.requested,
|
|
674
|
+
}
|
|
675
|
+
: null,
|
|
676
|
+
source: strategy.onTrade ? "exact-trades" : "coalesced-price",
|
|
677
|
+
priceSampleMs: strategy.onTrade ? null : priceSampleMs,
|
|
678
|
+
rawTrades: rawRows.length,
|
|
679
|
+
usableTrades: exact.length,
|
|
680
|
+
replayEvents: replay.length,
|
|
681
|
+
coalescedAway: strategy.onTrade ? 0 : exact.length - replay.length,
|
|
682
|
+
range: {
|
|
683
|
+
from: new Date(replay[0]!.price.atMs).toISOString(),
|
|
684
|
+
to: new Date(replay.at(-1)!.price.atMs).toISOString(),
|
|
685
|
+
},
|
|
686
|
+
params: inputParams,
|
|
687
|
+
executionAssumptions: {
|
|
688
|
+
slippageBps,
|
|
689
|
+
venueFeeBps,
|
|
690
|
+
networkFeeSol,
|
|
691
|
+
fill: "next-observable-event",
|
|
692
|
+
},
|
|
693
|
+
summary: {
|
|
694
|
+
startingSol,
|
|
695
|
+
endingCashSol: cashSol,
|
|
696
|
+
endingTokenUi: tokenUi,
|
|
697
|
+
finalPriceSol: finalPrice,
|
|
698
|
+
finalEquitySol,
|
|
699
|
+
returnPct,
|
|
700
|
+
buys: executions.filter((row) => row.side === "buy" && row.signature)
|
|
701
|
+
.length,
|
|
702
|
+
sells: executions.filter((row) => row.side === "sell" && row.signature)
|
|
703
|
+
.length,
|
|
704
|
+
notifications: notifications.length,
|
|
705
|
+
stopped,
|
|
706
|
+
stopReason,
|
|
707
|
+
},
|
|
708
|
+
notifications,
|
|
709
|
+
executions,
|
|
710
|
+
finalState: ctx.state,
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
if (args.flags.has("json")) {
|
|
714
|
+
args.emit(
|
|
715
|
+
`${JSON.stringify(result, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2)}\n`,
|
|
716
|
+
);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
args.emit(
|
|
721
|
+
`STRATEGY BACKTEST\n` +
|
|
722
|
+
`strategy=${result.strategy}\n` +
|
|
723
|
+
`mint=${mint}\n` +
|
|
724
|
+
`source=${result.source}${result.priceSampleMs ? ` sample=${result.priceSampleMs}ms` : ""}\n` +
|
|
725
|
+
`${result.researchCache ? `cache=${result.researchCache.path} indexed=${result.researchCache.indexed} requested=${result.researchCache.hydratedRequested}\n` : ""}` +
|
|
726
|
+
`history=${result.rawTrades} trades, replay=${result.replayEvents}, coalesced=${result.coalescedAway}\n` +
|
|
727
|
+
`range=${result.range.from} -> ${result.range.to}\n` +
|
|
728
|
+
`notifications=${result.summary.notifications} buys=${result.summary.buys} sells=${result.summary.sells}\n` +
|
|
729
|
+
`equity=${result.summary.finalEquitySol.toFixed(6)} SOL return=${fmtPct(result.summary.returnPct)}\n`,
|
|
730
|
+
);
|
|
731
|
+
for (const notice of notifications.slice(0, 50)) {
|
|
732
|
+
args.emit(
|
|
733
|
+
`${new Date(notice.atMs).toISOString()} ALERT ${notice.message} ${notice.data == null ? "" : JSON.stringify(notice.data)}\n`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (notifications.length > 50)
|
|
737
|
+
args.emit(
|
|
738
|
+
`... ${notifications.length - 50} more notifications; use --json for all\n`,
|
|
739
|
+
);
|
|
740
|
+
}
|