@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.
@@ -0,0 +1,1247 @@
1
+ import { resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { configure, createMeasure } from "measure-fn";
4
+ import {
5
+ Connection,
6
+ PublicKey,
7
+ type Logs,
8
+ type ParsedTransactionWithMeta,
9
+ } from "@solana/web3.js";
10
+ import { connectPriceFeed } from "./price-feed-client.ts";
11
+ import type { PriceFeedPrice } from "./price-feed-protocol.ts";
12
+ import {
13
+ RaydiumService,
14
+ createTraderSolard,
15
+ resolveTradeAsset,
16
+ sol,
17
+ } from "@solard/core";
18
+
19
+ type Flags = Map<string, string>;
20
+ type Emit = (value: string) => void;
21
+ type Trader = ReturnType<typeof createTraderSolard>;
22
+ type ExecutionVenue = "solard" | "raydium-launchlab" | "raydium";
23
+
24
+ export type StrategyTrade = {
25
+ id: string;
26
+ signature: string;
27
+ slot: number;
28
+ atMs: number;
29
+ mint: string;
30
+ venue: string;
31
+ side: "buy" | "sell";
32
+ trader: string;
33
+ isMine: boolean;
34
+ tokenAmountRaw: bigint;
35
+ tokenAmountUi: number;
36
+ quote: "SOL" | "USDC";
37
+ quoteAmountUi: number;
38
+ priceSol: number | null;
39
+ priceUsd: number | null;
40
+ marketCapUsd: number | null;
41
+ };
42
+
43
+ export type StrategyPriceTick = {
44
+ signature: string;
45
+ slot: number;
46
+ atMs: number;
47
+ mint: string;
48
+ priceSol: number | null;
49
+ priceUsd: number | null;
50
+ marketCapUsd: number | null;
51
+ };
52
+
53
+ export type StrategyPosition = {
54
+ tokenRaw: bigint;
55
+ tokenUi: number | null;
56
+ tokenDecimals: number | null;
57
+ solLamports: bigint;
58
+ sol: number;
59
+ };
60
+
61
+ export type StrategyExecution = {
62
+ live: boolean;
63
+ side: "buy" | "sell";
64
+ requested: number;
65
+ reason: string | null;
66
+ venue: string | null;
67
+ signature: string | null;
68
+ tokenDeltaRaw: bigint | null;
69
+ solDeltaLamports: bigint | null;
70
+ completedAt: number;
71
+ };
72
+
73
+ export type TradeStrategyContext<State = Record<string, unknown>> = {
74
+ readonly mint: string;
75
+ readonly wallet: string;
76
+ readonly walletAddress: string;
77
+ readonly live: boolean;
78
+ readonly state: State;
79
+ readonly params: Record<string, unknown>;
80
+ readonly priceCount: number;
81
+ readonly lastPrice: StrategyPriceTick | null;
82
+ readonly tradeCount: number;
83
+ readonly lastTrade: StrategyTrade | null;
84
+ buy(input: { sol: number; reason?: string }): Promise<StrategyExecution>;
85
+ sell(input: { percent: number; reason?: string }): Promise<StrategyExecution>;
86
+ position(): Promise<StrategyPosition>;
87
+ samplePrice(): Promise<{ price: number; venue: string }>;
88
+ log(message: string, data?: unknown): void;
89
+ notify(message: string, data?: unknown): void;
90
+ stop(reason?: string): void;
91
+ };
92
+
93
+ export type TradeStrategy<State = Record<string, unknown>> = {
94
+ name?: string;
95
+ history?: { mode?: "price" | "exact"; sampleMs?: number };
96
+ state?: State | (() => State | Promise<State>);
97
+ onStart?(ctx: TradeStrategyContext<State>): void | Promise<void>;
98
+ onPrice?(
99
+ ctx: TradeStrategyContext<State>,
100
+ price: StrategyPriceTick,
101
+ ): void | Promise<void>;
102
+ onTrade?(
103
+ ctx: TradeStrategyContext<State>,
104
+ trade: StrategyTrade,
105
+ ): void | Promise<void>;
106
+ onError?(
107
+ ctx: TradeStrategyContext<State>,
108
+ error: unknown,
109
+ trade: StrategyTrade | null,
110
+ ): void | Promise<void>;
111
+ onStop?(
112
+ ctx: TradeStrategyContext<State>,
113
+ reason: string,
114
+ ): void | Promise<void>;
115
+ };
116
+
117
+ export function defineTradeStrategy<State>(
118
+ strategy: TradeStrategy<State>,
119
+ ): TradeStrategy<State> {
120
+ return strategy;
121
+ }
122
+
123
+ type RawBalance = {
124
+ accountIndex: number;
125
+ mint: string;
126
+ owner: string | null;
127
+ raw: bigint;
128
+ };
129
+
130
+ const PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
131
+ const PUMP_AMM_PROGRAM = "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA";
132
+ const RAYDIUM_LAUNCHPAD_PROGRAM = "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj";
133
+ const WSOL_MINT = "So11111111111111111111111111111111111111112";
134
+ const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
135
+ const LAMPORTS_PER_SOL = 1_000_000_000;
136
+
137
+ function flag(flags: Flags, key: string): string | undefined {
138
+ const value = flags.get(key);
139
+ return value && value !== "true" ? value : undefined;
140
+ }
141
+
142
+ function numberFlag(flags: Flags, key: string, fallback: number): number {
143
+ const value = flag(flags, key);
144
+ if (value == null) return fallback;
145
+ const parsed = Number(value);
146
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
147
+ return parsed;
148
+ }
149
+
150
+ function integerFlag(flags: Flags, key: string, fallback: number): number {
151
+ const value = numberFlag(flags, key, fallback);
152
+ if (!Number.isInteger(value) || value <= 0)
153
+ throw new Error(`--${key} must be a positive integer`);
154
+ return value;
155
+ }
156
+
157
+ function rpcReadRate(flags: Flags): { hard: number; read: number } {
158
+ const raw = flag(flags, "rpc-rps") ?? process.env.SLRD_RPC_MAX_RPS ?? "5";
159
+ const hardValue = Number(raw);
160
+ if (!Number.isFinite(hardValue) || hardValue <= 0)
161
+ throw new Error(`Invalid RPC rate: ${raw}`);
162
+ const hard = Math.max(1, Math.floor(hardValue));
163
+ const defaultRead = hard > 2 ? Math.min(3, hard - 2) : 1;
164
+ const readRaw = flag(flags, "rpc-read-rps");
165
+ const readValue = readRaw == null ? defaultRead : Number(readRaw);
166
+ if (!Number.isFinite(readValue) || readValue <= 0)
167
+ throw new Error(`Invalid --rpc-read-rps: ${readRaw}`);
168
+ return { hard, read: Math.max(1, Math.min(hard, Math.floor(readValue))) };
169
+ }
170
+
171
+ class RpcPacer {
172
+ private tail: Promise<unknown> = Promise.resolve();
173
+ private nextAt = 0;
174
+ private pending = 0;
175
+
176
+ constructor(readonly rps: number) {}
177
+
178
+ get queued(): number {
179
+ return this.pending;
180
+ }
181
+
182
+ run<T>(fn: () => Promise<T>): Promise<T> {
183
+ this.pending += 1;
184
+ const step = Math.ceil(1_000 / this.rps);
185
+ const execute = async () => {
186
+ const wait = Math.max(0, this.nextAt - Date.now());
187
+ if (wait > 0) await sleep(wait);
188
+ this.nextAt = Math.max(this.nextAt, Date.now()) + step;
189
+ try {
190
+ return await fn();
191
+ } finally {
192
+ this.pending -= 1;
193
+ }
194
+ };
195
+ const result = this.tail.then(execute, execute);
196
+ this.tail = result.then(
197
+ () => undefined,
198
+ () => undefined,
199
+ );
200
+ return result;
201
+ }
202
+ }
203
+
204
+ function required(flags: Flags, key: string): string {
205
+ const value = flag(flags, key);
206
+ if (!value) throw new Error(`Missing --${key}`);
207
+ return value;
208
+ }
209
+
210
+ function rpcUrl(flags: Flags): string {
211
+ const value =
212
+ flag(flags, "rpc") ??
213
+ process.env.RPC_ENDPOINT?.trim() ??
214
+ process.env.SOLANA_RPC_URL?.trim() ??
215
+ process.env.HELIUS_RPC_URL?.trim();
216
+ if (!value)
217
+ throw new Error(
218
+ "strategy run requires RPC_ENDPOINT, SOLANA_RPC_URL, HELIUS_RPC_URL, or --rpc <url>",
219
+ );
220
+ return value;
221
+ }
222
+
223
+ function liveEnabled(): boolean {
224
+ return [
225
+ "SOLARD_ENABLE_LIVE_TRADES",
226
+ "SLRD_ENABLE_LIVE_TRADES",
227
+ "SOLWAL_ENABLE_LIVE_TRADES",
228
+ ].some((key) => process.env[key] === "1");
229
+ }
230
+
231
+ function errorText(error: unknown): string {
232
+ return error instanceof Error ? error.message : String(error);
233
+ }
234
+
235
+ async function parsedTransaction(
236
+ connection: Connection,
237
+ signature: string,
238
+ ): Promise<ParsedTransactionWithMeta | null> {
239
+ return (await connection.getParsedTransaction(signature, {
240
+ commitment: "confirmed",
241
+ maxSupportedTransactionVersion: 1,
242
+ } as any)) as ParsedTransactionWithMeta | null;
243
+ }
244
+
245
+ function keyText(value: unknown): string | null {
246
+ if (typeof value === "string") return value;
247
+ if (value && typeof (value as any).toBase58 === "function")
248
+ return (value as any).toBase58();
249
+ if (value && typeof (value as any).pubkey?.toBase58 === "function")
250
+ return (value as any).pubkey.toBase58();
251
+ if (value && typeof (value as any).pubkey === "string")
252
+ return (value as any).pubkey;
253
+ return null;
254
+ }
255
+
256
+ function accountKeys(tx: ParsedTransactionWithMeta): string[] {
257
+ return ((tx.transaction.message as any).accountKeys ?? [])
258
+ .map((row: unknown) => keyText(row))
259
+ .filter((row: string | null): row is string => Boolean(row));
260
+ }
261
+
262
+ function signerKeys(tx: ParsedTransactionWithMeta): Set<string> {
263
+ const rows = ((tx.transaction.message as any).accountKeys ?? []) as any[];
264
+ return new Set(
265
+ rows
266
+ .filter((row) => row && typeof row === "object" && row.signer === true)
267
+ .map((row) => keyText(row))
268
+ .filter((row): row is string => Boolean(row)),
269
+ );
270
+ }
271
+
272
+ function rawAmount(row: any): bigint {
273
+ try {
274
+ return BigInt(String(row?.uiTokenAmount?.amount ?? "0"));
275
+ } catch {
276
+ return 0n;
277
+ }
278
+ }
279
+
280
+ function tokenBalances(rows: readonly any[] | null | undefined): RawBalance[] {
281
+ return (rows ?? []).map((row) => ({
282
+ accountIndex: Number(row.accountIndex),
283
+ mint: String(row.mint ?? ""),
284
+ owner: row.owner ? String(row.owner) : null,
285
+ raw: rawAmount(row),
286
+ }));
287
+ }
288
+
289
+ function ownerTokenDeltaRaw(
290
+ tx: ParsedTransactionWithMeta,
291
+ mint: string,
292
+ owner: string,
293
+ ): bigint {
294
+ const pre = tokenBalances(tx.meta?.preTokenBalances);
295
+ const post = tokenBalances(tx.meta?.postTokenBalances);
296
+ const indices = new Set<number>();
297
+ for (const row of [...pre, ...post]) {
298
+ if (row.mint === mint && row.owner === owner) indices.add(row.accountIndex);
299
+ }
300
+ let total = 0n;
301
+ for (const index of indices) {
302
+ const before =
303
+ pre.find((row) => row.accountIndex === index && row.mint === mint)?.raw ??
304
+ 0n;
305
+ const after =
306
+ post.find((row) => row.accountIndex === index && row.mint === mint)
307
+ ?.raw ?? 0n;
308
+ total += after - before;
309
+ }
310
+ return total;
311
+ }
312
+
313
+ function tokenAccountRentDelta(
314
+ tx: ParsedTransactionWithMeta,
315
+ owner: string,
316
+ ): bigint {
317
+ if (!tx.meta) return 0n;
318
+ const pre = tokenBalances(tx.meta.preTokenBalances);
319
+ const post = tokenBalances(tx.meta.postTokenBalances);
320
+ const indices = new Set(
321
+ [...pre, ...post]
322
+ .filter((row) => row.owner === owner)
323
+ .map((row) => row.accountIndex),
324
+ );
325
+ let total = 0n;
326
+ for (const index of indices) {
327
+ const before = BigInt(Math.trunc(Number(tx.meta.preBalances[index] ?? 0)));
328
+ const after = BigInt(Math.trunc(Number(tx.meta.postBalances[index] ?? 0)));
329
+ const pairPre = pre.find((row) => row.accountIndex === index);
330
+ const pairPost = post.find((row) => row.accountIndex === index);
331
+ let delta = after - before;
332
+ if ((pairPost?.mint ?? pairPre?.mint) === WSOL_MINT) {
333
+ delta -= (pairPost?.raw ?? 0n) - (pairPre?.raw ?? 0n);
334
+ }
335
+ total += delta;
336
+ }
337
+ return total;
338
+ }
339
+
340
+ function economicSolDelta(
341
+ tx: ParsedTransactionWithMeta,
342
+ owner: string,
343
+ ): bigint | null {
344
+ if (!tx.meta) return null;
345
+ const keys = accountKeys(tx);
346
+ const index = keys.indexOf(owner);
347
+ if (index < 0) return null;
348
+ const before = tx.meta.preBalances[index];
349
+ const after = tx.meta.postBalances[index];
350
+ if (before == null || after == null) return null;
351
+ const native =
352
+ BigInt(Math.trunc(Number(after))) - BigInt(Math.trunc(Number(before)));
353
+ const fee = keys[0] === owner ? BigInt(tx.meta.fee ?? 0) : 0n;
354
+ const rent = tokenAccountRentDelta(tx, owner);
355
+ const wsol = ownerTokenDeltaRaw(tx, WSOL_MINT, owner);
356
+ return native + fee + rent + wsol;
357
+ }
358
+
359
+ function venueFromTransaction(tx: ParsedTransactionWithMeta): string {
360
+ const keys = new Set(accountKeys(tx));
361
+ if (keys.has(PUMP_AMM_PROGRAM)) return "pumpswap";
362
+ if (keys.has(PUMP_PROGRAM)) return "pump-curve";
363
+ if (keys.has(RAYDIUM_LAUNCHPAD_PROGRAM)) return "raydium-launchlab";
364
+ return "raydium-or-other";
365
+ }
366
+
367
+ function parseTrades(args: {
368
+ tx: ParsedTransactionWithMeta;
369
+ signature: string;
370
+ mint: string;
371
+ decimals: number;
372
+ supplyUi: number;
373
+ walletAddress: string;
374
+ solUsd: number | null;
375
+ }): StrategyTrade[] {
376
+ const tx = args.tx;
377
+ if (!tx.meta || tx.meta.err || tx.blockTime == null) return [];
378
+ const signers = signerKeys(tx);
379
+ const owners = new Set<string>();
380
+ for (const row of [
381
+ ...tokenBalances(tx.meta.preTokenBalances),
382
+ ...tokenBalances(tx.meta.postTokenBalances),
383
+ ]) {
384
+ if (row.mint === args.mint && row.owner && signers.has(row.owner)) {
385
+ owners.add(row.owner);
386
+ }
387
+ }
388
+ const venue = venueFromTransaction(tx);
389
+ const out: StrategyTrade[] = [];
390
+ for (const owner of owners) {
391
+ const targetDelta = ownerTokenDeltaRaw(tx, args.mint, owner);
392
+ if (targetDelta === 0n) continue;
393
+ const side = targetDelta > 0n ? "buy" : "sell";
394
+ const tokenAmountRaw = targetDelta < 0n ? -targetDelta : targetDelta;
395
+ const tokenAmountUi = Number(tokenAmountRaw) / 10 ** args.decimals;
396
+ if (!(tokenAmountUi > 0) || !Number.isFinite(tokenAmountUi)) continue;
397
+
398
+ const solDelta = economicSolDelta(tx, owner);
399
+ if (
400
+ solDelta != null &&
401
+ ((side === "buy" && solDelta < 0n) || (side === "sell" && solDelta > 0n))
402
+ ) {
403
+ const quoteAmountUi =
404
+ Number(solDelta < 0n ? -solDelta : solDelta) / LAMPORTS_PER_SOL;
405
+ if (!(quoteAmountUi > 0) || !Number.isFinite(quoteAmountUi)) continue;
406
+ const priceSol = quoteAmountUi / tokenAmountUi;
407
+ const priceUsd = args.solUsd == null ? null : priceSol * args.solUsd;
408
+ out.push({
409
+ id: `${args.signature}:${owner}:${side}`,
410
+ signature: args.signature,
411
+ slot: tx.slot,
412
+ atMs: tx.blockTime * 1_000,
413
+ mint: args.mint,
414
+ venue,
415
+ side,
416
+ trader: owner,
417
+ isMine: owner === args.walletAddress,
418
+ tokenAmountRaw,
419
+ tokenAmountUi,
420
+ quote: "SOL",
421
+ quoteAmountUi,
422
+ priceSol,
423
+ priceUsd,
424
+ marketCapUsd:
425
+ priceUsd != null && args.supplyUi > 0
426
+ ? priceUsd * args.supplyUi
427
+ : null,
428
+ });
429
+ continue;
430
+ }
431
+
432
+ const usdcDelta = ownerTokenDeltaRaw(tx, USDC_MINT, owner);
433
+ if (
434
+ (side === "buy" && usdcDelta < 0n) ||
435
+ (side === "sell" && usdcDelta > 0n)
436
+ ) {
437
+ const quoteAmountUi =
438
+ Number(usdcDelta < 0n ? -usdcDelta : usdcDelta) / 1_000_000;
439
+ if (!(quoteAmountUi > 0) || !Number.isFinite(quoteAmountUi)) continue;
440
+ const priceUsd = quoteAmountUi / tokenAmountUi;
441
+ out.push({
442
+ id: `${args.signature}:${owner}:${side}`,
443
+ signature: args.signature,
444
+ slot: tx.slot,
445
+ atMs: tx.blockTime * 1_000,
446
+ mint: args.mint,
447
+ venue,
448
+ side,
449
+ trader: owner,
450
+ isMine: owner === args.walletAddress,
451
+ tokenAmountRaw,
452
+ tokenAmountUi,
453
+ quote: "USDC",
454
+ quoteAmountUi,
455
+ priceSol: args.solUsd == null ? null : priceUsd / args.solUsd,
456
+ priceUsd,
457
+ marketCapUsd: args.supplyUi > 0 ? priceUsd * args.supplyUi : null,
458
+ });
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
464
+ async function fetchJson(url: string): Promise<any> {
465
+ const controller = new AbortController();
466
+ const timer = setTimeout(() => controller.abort(), 5_000);
467
+ try {
468
+ const response = await fetch(url, {
469
+ headers: { accept: "application/json" },
470
+ signal: controller.signal,
471
+ });
472
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
473
+ return await response.json();
474
+ } finally {
475
+ clearTimeout(timer);
476
+ }
477
+ }
478
+
479
+ async function fetchSolUsd(): Promise<number> {
480
+ try {
481
+ const raw = await fetchJson(
482
+ "https://api.coinbase.com/v2/prices/SOL-USD/spot",
483
+ );
484
+ const value = Number(raw?.data?.amount);
485
+ if (Number.isFinite(value) && value > 0) return value;
486
+ } catch {}
487
+ const raw = await fetchJson(
488
+ "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd",
489
+ );
490
+ const value = Number(raw?.solana?.usd);
491
+ if (!Number.isFinite(value) || value <= 0)
492
+ throw new Error("SOL/USD price unavailable");
493
+ return value;
494
+ }
495
+
496
+ async function discoverRaydiumPools(mint: string): Promise<string[]> {
497
+ const query = new URLSearchParams({
498
+ mint1: mint,
499
+ poolType: "all",
500
+ poolSortField: "liquidity",
501
+ sortType: "desc",
502
+ pageSize: "12",
503
+ page: "1",
504
+ });
505
+ try {
506
+ const raw = await fetchJson(
507
+ `https://api-v3.raydium.io/pools/info/mint?${query}`,
508
+ );
509
+ const rows = Array.isArray(raw?.data?.data) ? raw.data.data : [];
510
+ return [
511
+ ...new Set(
512
+ rows
513
+ .map((row: any) => (typeof row?.id === "string" ? row.id.trim() : ""))
514
+ .filter(Boolean),
515
+ ),
516
+ ];
517
+ } catch {
518
+ return [];
519
+ }
520
+ }
521
+
522
+ async function sleep(ms: number): Promise<void> {
523
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, ms)));
524
+ }
525
+
526
+ class StrategyExecutor {
527
+ private readonly raydium: RaydiumService;
528
+ private venueValue: ExecutionVenue | null = null;
529
+
530
+ constructor(
531
+ private readonly slrd: Trader,
532
+ readonly mint: string,
533
+ private readonly walletRef: string,
534
+ private readonly live: boolean,
535
+ private readonly slippageBps: number,
536
+ private readonly via: string,
537
+ private readonly settlementAttempts: number,
538
+ private readonly launchlabProbeSol: number,
539
+ ) {
540
+ this.raydium = new RaydiumService(slrd);
541
+ }
542
+
543
+ get venue(): string | null {
544
+ return this.venueValue;
545
+ }
546
+
547
+ async position(): Promise<StrategyPosition> {
548
+ const balances = await this.slrd.walletBalances(this.walletRef, [
549
+ this.mint,
550
+ ]);
551
+ const token =
552
+ balances.tokenBalances.find(
553
+ (row: any) => row.token?.mint === this.mint,
554
+ ) ??
555
+ balances.tokenBalances[0] ??
556
+ null;
557
+ const tokenRaw = BigInt(token?.amountRaw ?? 0n);
558
+ const tokenDecimals = Number.isInteger(token?.decimals)
559
+ ? Number(token.decimals)
560
+ : null;
561
+ return {
562
+ tokenRaw,
563
+ tokenUi:
564
+ tokenDecimals == null ? null : Number(tokenRaw) / 10 ** tokenDecimals,
565
+ tokenDecimals,
566
+ solLamports: BigInt(balances.solLamports),
567
+ sol: Number(balances.solLamports) / 1e9,
568
+ };
569
+ }
570
+
571
+ async samplePrice(): Promise<{ price: number; venue: string }> {
572
+ if (!this.venueValue) this.venueValue = await this.detectVenue();
573
+ if (this.venueValue === "raydium-launchlab") {
574
+ try {
575
+ return {
576
+ price: await this.launchLabPrice(),
577
+ venue: "raydium-launchlab",
578
+ };
579
+ } catch (error) {
580
+ if (await this.trySwitchToRaydium()) return await this.samplePrice();
581
+ if (await this.trySwitchToSolard()) return await this.samplePrice();
582
+ throw error;
583
+ }
584
+ }
585
+ if (this.venueValue === "raydium") {
586
+ try {
587
+ return { price: await this.raydiumPrice(), venue: "raydium" };
588
+ } catch (error) {
589
+ try {
590
+ await this.launchLabPrice();
591
+ this.venueValue = "raydium-launchlab";
592
+ return await this.samplePrice();
593
+ } catch {}
594
+ if (await this.trySwitchToSolard()) return await this.samplePrice();
595
+ throw error;
596
+ }
597
+ }
598
+ try {
599
+ const sampled = await this.slrd.samplePrice(this.mint);
600
+ if (sampled.quoteAsset.kind !== "native-sol")
601
+ throw new Error("Strategy convenience pricing requires a SOL market");
602
+ return { price: sampled.priceQuotePerToken, venue: sampled.venue };
603
+ } catch (error) {
604
+ try {
605
+ await this.launchLabPrice();
606
+ this.venueValue = "raydium-launchlab";
607
+ return await this.samplePrice();
608
+ } catch {}
609
+ if (await this.trySwitchToRaydium()) return await this.samplePrice();
610
+ throw error;
611
+ }
612
+ }
613
+
614
+ async buy(
615
+ amountSol: number,
616
+ reason: string | null,
617
+ ): Promise<StrategyExecution> {
618
+ if (!(amountSol > 0) || !Number.isFinite(amountSol))
619
+ throw new Error("buy SOL must be greater than zero");
620
+ if (!this.live) {
621
+ return {
622
+ live: false,
623
+ side: "buy",
624
+ requested: amountSol,
625
+ reason,
626
+ venue: this.venueValue,
627
+ signature: null,
628
+ tokenDeltaRaw: null,
629
+ solDeltaLamports: null,
630
+ completedAt: Date.now(),
631
+ };
632
+ }
633
+ const before = await this.position();
634
+ if (!this.venueValue) this.venueValue = await this.detectVenue();
635
+ const execution = await this.executeBuy(amountSol);
636
+ const after = await this.waitForSettlement("buy", before);
637
+ return {
638
+ live: true,
639
+ side: "buy",
640
+ requested: amountSol,
641
+ reason,
642
+ venue: execution.venue,
643
+ signature: execution.signature,
644
+ tokenDeltaRaw: after.tokenRaw - before.tokenRaw,
645
+ solDeltaLamports: after.solLamports - before.solLamports,
646
+ completedAt: Date.now(),
647
+ };
648
+ }
649
+
650
+ async sell(
651
+ percent: number,
652
+ reason: string | null,
653
+ ): Promise<StrategyExecution> {
654
+ if (!(percent > 0) || percent > 100 || !Number.isFinite(percent))
655
+ throw new Error("sell percent must be > 0 and <= 100");
656
+ if (!this.live) {
657
+ return {
658
+ live: false,
659
+ side: "sell",
660
+ requested: percent,
661
+ reason,
662
+ venue: this.venueValue,
663
+ signature: null,
664
+ tokenDeltaRaw: null,
665
+ solDeltaLamports: null,
666
+ completedAt: Date.now(),
667
+ };
668
+ }
669
+ const before = await this.position();
670
+ if (before.tokenRaw <= 0n)
671
+ throw new Error("Wallet has no token balance to sell");
672
+ if (!this.venueValue) this.venueValue = await this.detectVenue();
673
+ const execution = await this.executeSell(percent, before);
674
+ const after = await this.waitForSettlement("sell", before);
675
+ return {
676
+ live: true,
677
+ side: "sell",
678
+ requested: percent,
679
+ reason,
680
+ venue: execution.venue,
681
+ signature: execution.signature,
682
+ tokenDeltaRaw: after.tokenRaw - before.tokenRaw,
683
+ solDeltaLamports: after.solLamports - before.solLamports,
684
+ completedAt: Date.now(),
685
+ };
686
+ }
687
+
688
+ private async detectVenue(): Promise<ExecutionVenue> {
689
+ const token = this.slrd.resolveToken(this.mint);
690
+ const user = this.slrd.signer(this.walletRef).publicKey;
691
+ try {
692
+ const route = await this.slrd.route(token, user);
693
+ if (route.market.quoteAsset.kind !== "native-sol")
694
+ throw new Error("non-SOL native route");
695
+ return "solard";
696
+ } catch {}
697
+ try {
698
+ await this.launchLabPrice();
699
+ return "raydium-launchlab";
700
+ } catch {}
701
+ await this.raydiumPrice();
702
+ return "raydium";
703
+ }
704
+
705
+ private async launchLabPrice(): Promise<number> {
706
+ const quote = await resolveTradeAsset(this.slrd, "SOL");
707
+ const prepared = await this.raydium.buildLaunchLabBuy({
708
+ wallet: this.walletRef,
709
+ mint: this.mint,
710
+ quoteMint: quote.mint,
711
+ amountRaw: sol(this.launchlabProbeSol).raw,
712
+ slippageBps: this.slippageBps,
713
+ });
714
+ const expectedOut = Number(prepared.metadata.expectedOut);
715
+ if (!(expectedOut > 0) || !Number.isFinite(expectedOut))
716
+ throw new Error("LaunchLab quote returned no usable expectedOut");
717
+ return this.launchlabProbeSol / expectedOut;
718
+ }
719
+
720
+ private async raydiumPrice(): Promise<number> {
721
+ const [quote, token] = await Promise.all([
722
+ resolveTradeAsset(this.slrd, "SOL"),
723
+ resolveTradeAsset(this.slrd, this.mint),
724
+ ]);
725
+ const result = await this.raydium.quoteExactIn({
726
+ inputMint: quote.mint,
727
+ outputMint: this.mint,
728
+ amountRaw: sol(this.launchlabProbeSol).raw,
729
+ slippageBps: this.slippageBps,
730
+ });
731
+ const outUi = Number(result.outputRaw) / 10 ** token.decimals;
732
+ if (!(outUi > 0) || !Number.isFinite(outUi))
733
+ throw new Error("Raydium quote returned no usable token output");
734
+ return this.launchlabProbeSol / outUi;
735
+ }
736
+
737
+ private async executeBuy(
738
+ amountSol: number,
739
+ ): Promise<{ signature: string | null; venue: string }> {
740
+ if (this.venueValue === "raydium-launchlab") {
741
+ try {
742
+ const quote = await resolveTradeAsset(this.slrd, "SOL");
743
+ const prepared = await this.raydium.buildLaunchLabBuy({
744
+ wallet: this.walletRef,
745
+ mint: this.mint,
746
+ quoteMint: quote.mint,
747
+ amountRaw: sol(amountSol).raw,
748
+ slippageBps: this.slippageBps,
749
+ });
750
+ const result = await this.raydium.executePrepared(prepared, {
751
+ live: true,
752
+ simulate: true,
753
+ skipPreflight: false,
754
+ commitment: "confirmed",
755
+ });
756
+ return {
757
+ signature: result.signatures.at(-1) ?? null,
758
+ venue: "raydium-launchlab",
759
+ };
760
+ } catch (error) {
761
+ if (await this.trySwitchToRaydium())
762
+ return await this.executeBuy(amountSol);
763
+ if (!(await this.trySwitchToSolard())) throw error;
764
+ }
765
+ }
766
+ if (this.venueValue === "raydium") {
767
+ const quote = await resolveTradeAsset(this.slrd, "SOL");
768
+ const prepared = await this.raydium.buildSwapExactIn({
769
+ wallet: this.walletRef,
770
+ inputMint: quote.mint,
771
+ outputMint: this.mint,
772
+ amountRaw: sol(amountSol).raw,
773
+ slippageBps: this.slippageBps,
774
+ });
775
+ const result = await this.raydium.executePrepared(prepared, {
776
+ live: true,
777
+ simulate: true,
778
+ skipPreflight: false,
779
+ commitment: "confirmed",
780
+ });
781
+ return {
782
+ signature: result.signatures.at(-1) ?? null,
783
+ venue: "raydium",
784
+ };
785
+ }
786
+ const value = await this.slrd.buy(
787
+ this.mint,
788
+ this.walletRef,
789
+ sol(amountSol),
790
+ {
791
+ slippageBps: this.slippageBps,
792
+ via: this.via as any,
793
+ },
794
+ );
795
+ return {
796
+ signature: (value as any)?.signature ?? null,
797
+ venue: "solard",
798
+ };
799
+ }
800
+
801
+ private async executeSell(
802
+ percent: number,
803
+ before: StrategyPosition,
804
+ ): Promise<{ signature: string | null; venue: string }> {
805
+ const bps = Math.max(1, Math.min(10_000, Math.round(percent * 100)));
806
+ const amountRaw = (before.tokenRaw * BigInt(bps)) / 10_000n;
807
+ if (amountRaw <= 0n) throw new Error("Calculated sell amount is zero");
808
+ if (this.venueValue === "raydium-launchlab") {
809
+ try {
810
+ const quote = await resolveTradeAsset(this.slrd, "SOL");
811
+ const prepared = await this.raydium.buildLaunchLabSell({
812
+ wallet: this.walletRef,
813
+ mint: this.mint,
814
+ quoteMint: quote.mint,
815
+ amountRaw,
816
+ slippageBps: this.slippageBps,
817
+ });
818
+ const result = await this.raydium.executePrepared(prepared, {
819
+ live: true,
820
+ simulate: true,
821
+ skipPreflight: false,
822
+ commitment: "confirmed",
823
+ });
824
+ return {
825
+ signature: result.signatures.at(-1) ?? null,
826
+ venue: "raydium-launchlab",
827
+ };
828
+ } catch (error) {
829
+ if (await this.trySwitchToRaydium())
830
+ return await this.executeSell(percent, before);
831
+ if (!(await this.trySwitchToSolard())) throw error;
832
+ }
833
+ }
834
+ if (this.venueValue === "raydium") {
835
+ const quote = await resolveTradeAsset(this.slrd, "SOL");
836
+ const prepared = await this.raydium.buildSwapExactIn({
837
+ wallet: this.walletRef,
838
+ inputMint: this.mint,
839
+ outputMint: quote.mint,
840
+ amountRaw,
841
+ slippageBps: this.slippageBps,
842
+ });
843
+ const result = await this.raydium.executePrepared(prepared, {
844
+ live: true,
845
+ simulate: true,
846
+ skipPreflight: false,
847
+ commitment: "confirmed",
848
+ });
849
+ return {
850
+ signature: result.signatures.at(-1) ?? null,
851
+ venue: "raydium",
852
+ };
853
+ }
854
+ const value = await this.slrd.sell(this.mint, this.walletRef, {
855
+ bps,
856
+ slippageBps: this.slippageBps,
857
+ via: this.via as any,
858
+ });
859
+ return {
860
+ signature: (value as any)?.signature ?? null,
861
+ venue: "solard",
862
+ };
863
+ }
864
+
865
+ private async trySwitchToRaydium(): Promise<boolean> {
866
+ try {
867
+ await this.raydiumPrice();
868
+ this.venueValue = "raydium";
869
+ return true;
870
+ } catch {
871
+ return false;
872
+ }
873
+ }
874
+
875
+ private async trySwitchToSolard(): Promise<boolean> {
876
+ try {
877
+ const token = this.slrd.resolveToken(this.mint);
878
+ const route = await this.slrd.route(
879
+ token,
880
+ this.slrd.signer(this.walletRef).publicKey,
881
+ );
882
+ if (route.market.quoteAsset.kind !== "native-sol") return false;
883
+ this.venueValue = "solard";
884
+ return true;
885
+ } catch {
886
+ return false;
887
+ }
888
+ }
889
+
890
+ private async waitForSettlement(
891
+ side: "buy" | "sell",
892
+ before: StrategyPosition,
893
+ ): Promise<StrategyPosition> {
894
+ let latest = before;
895
+ for (let attempt = 1; attempt <= this.settlementAttempts; attempt += 1) {
896
+ if (attempt > 1) await sleep(750);
897
+ latest = await this.position();
898
+ const changed =
899
+ side === "buy"
900
+ ? latest.tokenRaw > before.tokenRaw
901
+ : latest.tokenRaw < before.tokenRaw;
902
+ if (changed) return latest;
903
+ }
904
+ throw new Error(
905
+ `${side} did not produce a provable token balance delta after ${this.settlementAttempts} wallet reads`,
906
+ );
907
+ }
908
+ }
909
+
910
+ async function loadStrategy(path: string): Promise<TradeStrategy<any>> {
911
+ const absolute = resolve(path);
912
+ const module = (await import(
913
+ `${pathToFileURL(absolute).href}?strategy=${Date.now()}`
914
+ )) as { default?: unknown; strategy?: unknown };
915
+ const strategy = (module.default ?? module.strategy) as TradeStrategy<any>;
916
+ if (!strategy || typeof strategy !== "object")
917
+ throw new Error(`Strategy ${absolute} must default-export an object`);
918
+ if (
919
+ typeof strategy.onPrice !== "function" &&
920
+ typeof strategy.onTrade !== "function"
921
+ )
922
+ throw new Error(
923
+ `Strategy ${absolute} must define async onPrice(ctx, price) or legacy onTrade(ctx, trade)`,
924
+ );
925
+ return strategy;
926
+ }
927
+
928
+ async function initialState(strategy: TradeStrategy<any>): Promise<any> {
929
+ if (typeof strategy.state === "function") return await strategy.state();
930
+ if (strategy.state == null) return {};
931
+ return structuredClone(strategy.state);
932
+ }
933
+
934
+ function params(flags: Flags): Record<string, unknown> {
935
+ const raw = flag(flags, "params");
936
+ if (!raw) return {};
937
+ const value = JSON.parse(raw);
938
+ if (!value || typeof value !== "object" || Array.isArray(value))
939
+ throw new Error("--params must be a JSON object");
940
+ return value as Record<string, unknown>;
941
+ }
942
+
943
+ async function ensureToken(slrd: Trader, tokenRef: string): Promise<string> {
944
+ try {
945
+ return slrd.resolveToken(tokenRef).mint;
946
+ } catch {
947
+ await slrd.addToken(tokenRef);
948
+ return slrd.resolveToken(tokenRef).mint;
949
+ }
950
+ }
951
+
952
+ export async function runTradeStrategyCommand(args: {
953
+ values: string[];
954
+ flags: Flags;
955
+ emit: Emit;
956
+ }): Promise<void> {
957
+ const path = args.values[1];
958
+ if (!path)
959
+ throw new Error(
960
+ "Usage: slrd strategy run <file.ts> --token <mint|alias> --wallet <wallet> [--params '{...}'] [--live]",
961
+ );
962
+ const tokenRef = required(args.flags, "token");
963
+ const walletRef = required(args.flags, "wallet");
964
+ const live = args.flags.has("live");
965
+ if (live && !liveEnabled())
966
+ throw new Error(
967
+ "Live strategy execution requires --live and SOLARD_ENABLE_LIVE_TRADES=1",
968
+ );
969
+ const json = args.flags.has("json");
970
+ configure({
971
+ silent: false,
972
+ logger(_event: unknown, next?: () => void) {
973
+ next?.();
974
+ },
975
+ });
976
+ const m = createMeasure("slrd:trade-strategy", { maxResultLength: 1600 });
977
+ const report = <T extends Record<string, unknown>>(
978
+ label: string,
979
+ value: T,
980
+ ): T => {
981
+ if (json) {
982
+ args.emit(`${JSON.stringify({ type: label, ...value })}\n`);
983
+ return value;
984
+ }
985
+ return m.sync(
986
+ { start: () => label, end: (result: T) => result },
987
+ () => value,
988
+ );
989
+ };
990
+ const continueOnError = args.flags.has("continue-on-error");
991
+ const slippageBps = Math.max(
992
+ 1,
993
+ Math.min(10_000, Math.trunc(numberFlag(args.flags, "slippage-bps", 500))),
994
+ );
995
+ const settlementAttempts = integerFlag(args.flags, "settlement-attempts", 24);
996
+ const launchlabProbeSol = Math.max(
997
+ 0.000001,
998
+ numberFlag(args.flags, "launchlab-probe-sol", 0.001),
999
+ );
1000
+ const via = flag(args.flags, "sender") ?? "rpc";
1001
+ const heartbeatMs = Math.max(
1002
+ 1_000,
1003
+ Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000)),
1004
+ );
1005
+ const rpc = rpcUrl(args.flags);
1006
+ const feed =
1007
+ flag(args.flags, "feed") ??
1008
+ process.env.SOLARD_FEED_URL?.trim() ??
1009
+ "ws://127.0.0.1:8788/ws";
1010
+ const strategy = await loadStrategy(path);
1011
+ if (typeof strategy.onPrice !== "function")
1012
+ throw new Error(
1013
+ `Live shared-feed strategy ${path} must define onPrice(ctx, price). ` +
1014
+ `Legacy onTrade is retained for historical exact-trade backtests, not per-agent live RPC subscriptions.`,
1015
+ );
1016
+ const state = await initialState(strategy);
1017
+ const strategyParams = params(args.flags);
1018
+ const slrd = createTraderSolard({ rpcUrl: rpc });
1019
+ let stopped = false;
1020
+ let stopReason = "stopped";
1021
+ let priceCount = 0;
1022
+ let lastPrice: StrategyPriceTick | null = null;
1023
+ let callbackErrors = 0;
1024
+ let feedErrors = 0;
1025
+ let feedConnected = false;
1026
+ let callbackQueue: Promise<void> = Promise.resolve();
1027
+
1028
+ try {
1029
+ const mint = await ensureToken(slrd, tokenRef);
1030
+ const walletAddress = slrd.resolveWallet(walletRef).address.toBase58();
1031
+ const executor = new StrategyExecutor(
1032
+ slrd,
1033
+ mint,
1034
+ walletRef,
1035
+ live,
1036
+ slippageBps,
1037
+ via,
1038
+ settlementAttempts,
1039
+ launchlabProbeSol,
1040
+ );
1041
+
1042
+ const log = (message: string, data?: unknown) => {
1043
+ report("strategy log", {
1044
+ at: new Date().toISOString(),
1045
+ strategy: strategy.name ?? path,
1046
+ message,
1047
+ ...(data === undefined ? {} : { data }),
1048
+ });
1049
+ };
1050
+ const notify = (message: string, data?: unknown) => {
1051
+ if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
1052
+ report("strategy notification", {
1053
+ at: new Date().toISOString(),
1054
+ strategy: strategy.name ?? path,
1055
+ mint,
1056
+ message,
1057
+ ...(data === undefined ? {} : { data }),
1058
+ });
1059
+ };
1060
+
1061
+ const context = {
1062
+ get mint() {
1063
+ return mint;
1064
+ },
1065
+ get wallet() {
1066
+ return walletRef;
1067
+ },
1068
+ get walletAddress() {
1069
+ return walletAddress;
1070
+ },
1071
+ get live() {
1072
+ return live;
1073
+ },
1074
+ get state() {
1075
+ return state;
1076
+ },
1077
+ get params() {
1078
+ return strategyParams;
1079
+ },
1080
+ get priceCount() {
1081
+ return priceCount;
1082
+ },
1083
+ get lastPrice() {
1084
+ return lastPrice;
1085
+ },
1086
+ get tradeCount() {
1087
+ return 0;
1088
+ },
1089
+ get lastTrade() {
1090
+ return null;
1091
+ },
1092
+ async buy(input: { sol: number; reason?: string }) {
1093
+ const result = await executor.buy(input.sol, input.reason ?? null);
1094
+ report("strategy execution", {
1095
+ at: new Date(result.completedAt).toISOString(),
1096
+ ...result,
1097
+ side: "buy",
1098
+ tokenDeltaRaw: result.tokenDeltaRaw?.toString() ?? null,
1099
+ solDeltaLamports: result.solDeltaLamports?.toString() ?? null,
1100
+ });
1101
+ return result;
1102
+ },
1103
+ async sell(input: { percent: number; reason?: string }) {
1104
+ const result = await executor.sell(input.percent, input.reason ?? null);
1105
+ report("strategy execution", {
1106
+ at: new Date(result.completedAt).toISOString(),
1107
+ ...result,
1108
+ side: "sell",
1109
+ tokenDeltaRaw: result.tokenDeltaRaw?.toString() ?? null,
1110
+ solDeltaLamports: result.solDeltaLamports?.toString() ?? null,
1111
+ });
1112
+ return result;
1113
+ },
1114
+ position: () => executor.position(),
1115
+ samplePrice: () => executor.samplePrice(),
1116
+ log,
1117
+ notify,
1118
+ stop(reason?: string) {
1119
+ stopped = true;
1120
+ stopReason = reason?.trim() || "strategy requested stop";
1121
+ },
1122
+ } satisfies TradeStrategyContext<any>;
1123
+
1124
+ const handleCallbackError = async (error: unknown) => {
1125
+ callbackErrors += 1;
1126
+ if (strategy.onError) {
1127
+ try {
1128
+ await strategy.onError(context, error, null);
1129
+ } catch (handlerError) {
1130
+ report("strategy onError failure", {
1131
+ error: errorText(handlerError),
1132
+ });
1133
+ }
1134
+ }
1135
+ report("strategy callback error", { error: errorText(error) });
1136
+ if (!continueOnError) {
1137
+ stopped = true;
1138
+ stopReason = `callback error: ${errorText(error)}`;
1139
+ }
1140
+ };
1141
+
1142
+ const onPrice = async (event: PriceFeedPrice) => {
1143
+ if (event.mint !== mint || stopped) return;
1144
+ const price: StrategyPriceTick = {
1145
+ signature: event.signature ?? "feed",
1146
+ slot: event.slot ?? 0,
1147
+ atMs: event.atMs,
1148
+ mint,
1149
+ priceSol: event.priceSol,
1150
+ priceUsd: event.priceUsd,
1151
+ marketCapUsd: event.marketCapUsd,
1152
+ };
1153
+ if (price.priceSol == null && price.priceUsd == null) return;
1154
+ priceCount += 1;
1155
+ lastPrice = price;
1156
+ if (json) report("price", price as any);
1157
+ try {
1158
+ await strategy.onPrice!(context, price);
1159
+ } catch (error) {
1160
+ await handleCallbackError(error);
1161
+ }
1162
+ };
1163
+
1164
+ const controller = new AbortController();
1165
+ const feedClient = await connectPriceFeed({
1166
+ url: feed,
1167
+ subscribe: { op: "subscribe", mints: [mint] },
1168
+ signal: controller.signal,
1169
+ onStatus(event, data) {
1170
+ if (event === "connected") feedConnected = true;
1171
+ if (event === "connect-error") {
1172
+ feedConnected = false;
1173
+ feedErrors += 1;
1174
+ }
1175
+ report(`feed ${event}`, data ?? {});
1176
+ },
1177
+ onMessage(message) {
1178
+ if (message.type !== "price" || message.mint !== mint) return;
1179
+ callbackQueue = callbackQueue.then(() => onPrice(message));
1180
+ },
1181
+ });
1182
+
1183
+ report("strategy ready", {
1184
+ strategy: strategy.name ?? path,
1185
+ mint,
1186
+ wallet: walletRef,
1187
+ walletAddress,
1188
+ live,
1189
+ feed,
1190
+ event: "shared-price-feed",
1191
+ upstreamMarketSubscriptions: 0,
1192
+ });
1193
+
1194
+ if (strategy.onStart) {
1195
+ try {
1196
+ await strategy.onStart(context);
1197
+ } catch (error) {
1198
+ await handleCallbackError(error);
1199
+ }
1200
+ }
1201
+
1202
+ const stopSignal = () => {
1203
+ stopped = true;
1204
+ stopReason = "signal";
1205
+ controller.abort();
1206
+ };
1207
+ process.once("SIGINT", stopSignal);
1208
+ process.once("SIGTERM", stopSignal);
1209
+ const heartbeatTimer = setInterval(() => {
1210
+ report("strategy heartbeat", {
1211
+ feedConnected,
1212
+ prices: priceCount,
1213
+ callbackErrors,
1214
+ feedErrors,
1215
+ upstreamMarketSubscriptions: 0,
1216
+ executionVenue: executor.venue ?? "unresolved",
1217
+ });
1218
+ }, heartbeatMs);
1219
+
1220
+ try {
1221
+ while (!stopped) await sleep(250);
1222
+ } finally {
1223
+ clearInterval(heartbeatTimer);
1224
+ process.removeListener("SIGINT", stopSignal);
1225
+ process.removeListener("SIGTERM", stopSignal);
1226
+ controller.abort();
1227
+ feedClient.close();
1228
+ await callbackQueue.catch(() => undefined);
1229
+ if (strategy.onStop) {
1230
+ try {
1231
+ await strategy.onStop(context, stopReason);
1232
+ } catch (error) {
1233
+ report("strategy onStop failure", { error: errorText(error) });
1234
+ }
1235
+ }
1236
+ report("strategy stopped", {
1237
+ strategy: strategy.name ?? path,
1238
+ reason: stopReason,
1239
+ prices: priceCount,
1240
+ callbackErrors,
1241
+ feedErrors,
1242
+ });
1243
+ }
1244
+ } finally {
1245
+ slrd.close();
1246
+ }
1247
+ }