@solard/cli 0.2.19 → 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 +695 -146
- 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,278 @@
|
|
|
1
|
+
import { configure, createMeasure } from "measure-fn";
|
|
2
|
+
import { connectPriceFeed } from "./price-feed-client.ts";
|
|
3
|
+
import type { PriceFeedLaunch, PriceFeedPrice } from "./price-feed-protocol.ts";
|
|
4
|
+
|
|
5
|
+
type Flags = Map<string, string>;
|
|
6
|
+
type Emit = (value: string) => void;
|
|
7
|
+
type VenueId = "pump" | "raydium-launchlab";
|
|
8
|
+
type WatchState = {
|
|
9
|
+
mint: string;
|
|
10
|
+
venue: VenueId;
|
|
11
|
+
createdAtMs: number;
|
|
12
|
+
lastSeenAtMs: number;
|
|
13
|
+
qualified: boolean;
|
|
14
|
+
athMarketCapUsd: number | null;
|
|
15
|
+
lastNotifiedAthUsd: number | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function flag(flags: Flags, key: string): string | undefined {
|
|
19
|
+
const value = flags.get(key);
|
|
20
|
+
return value && value !== "true" ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function numberFlag(flags: Flags, key: string, fallback: number): number {
|
|
24
|
+
const value = flag(flags, key);
|
|
25
|
+
if (value == null) return fallback;
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function duration(value: string | undefined, fallbackMs: number): number {
|
|
32
|
+
if (!value) return fallbackMs;
|
|
33
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value.trim());
|
|
34
|
+
if (!match) throw new Error(`Invalid duration: ${value}`);
|
|
35
|
+
const scale = ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 } as const)[
|
|
36
|
+
(match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h"
|
|
37
|
+
];
|
|
38
|
+
return Math.max(100, Math.floor(Number(match[1]) * scale));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function venueList(flags: Flags): VenueId[] {
|
|
42
|
+
const raw = flag(flags, "venue") ?? "pump,raydium-launchlab";
|
|
43
|
+
const values = raw
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((value) => value.trim().toLowerCase())
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((value) =>
|
|
48
|
+
value === "launchlab" || value === "raydium" || value === "stonkfun"
|
|
49
|
+
? "raydium-launchlab"
|
|
50
|
+
: value,
|
|
51
|
+
);
|
|
52
|
+
const out = [...new Set(values)] as string[];
|
|
53
|
+
for (const value of out)
|
|
54
|
+
if (value !== "pump" && value !== "raydium-launchlab")
|
|
55
|
+
throw new Error(`Unsupported --venue value: ${value}`);
|
|
56
|
+
return out as VenueId[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function feedUrl(flags: Flags): string {
|
|
60
|
+
return (
|
|
61
|
+
flag(flags, "feed") ??
|
|
62
|
+
process.env.SOLARD_FEED_URL?.trim() ??
|
|
63
|
+
"ws://127.0.0.1:8788/ws"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runLaunchWatchCommand(args: {
|
|
68
|
+
flags: Flags;
|
|
69
|
+
emit: Emit;
|
|
70
|
+
forcedVenues?: VenueId[];
|
|
71
|
+
}): Promise<void> {
|
|
72
|
+
configure({
|
|
73
|
+
silent: false,
|
|
74
|
+
logger(_event: unknown, next?: () => void) {
|
|
75
|
+
next?.();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const json = args.flags.has("json");
|
|
79
|
+
const m = createMeasure("slrd:launch-watch", { maxResultLength: 1600 });
|
|
80
|
+
const report = <T extends Record<string, unknown>>(
|
|
81
|
+
label: string,
|
|
82
|
+
value: T,
|
|
83
|
+
): T => {
|
|
84
|
+
if (json) {
|
|
85
|
+
args.emit(`${JSON.stringify({ type: label, ...value })}\n`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
return m.sync(
|
|
89
|
+
{ start: () => label, end: (result: T) => result },
|
|
90
|
+
() => value,
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
const venues = args.forcedVenues ?? venueList(args.flags);
|
|
94
|
+
const minMarketCapUsd = numberFlag(args.flags, "min-mcap", 5_000);
|
|
95
|
+
const athStepPct = numberFlag(args.flags, "ath-step-pct", 20);
|
|
96
|
+
if (!(minMarketCapUsd > 0)) throw new Error("--min-mcap must be > 0");
|
|
97
|
+
if (!(athStepPct > 0)) throw new Error("--ath-step-pct must be > 0");
|
|
98
|
+
const trackTtlMs = duration(flag(args.flags, "track-ttl"), 30 * 60_000);
|
|
99
|
+
const showNew = args.flags.has("show-new");
|
|
100
|
+
const showPrices = args.flags.has("trades") || args.flags.has("prices");
|
|
101
|
+
const url = feedUrl(args.flags);
|
|
102
|
+
const states = new Map<string, WatchState>();
|
|
103
|
+
let launches = 0;
|
|
104
|
+
let prices = 0;
|
|
105
|
+
let qualified = 0;
|
|
106
|
+
let thresholdNotifications = 0;
|
|
107
|
+
let athNotifications = 0;
|
|
108
|
+
let ignoredMayhem = 0;
|
|
109
|
+
let feedConnected = false;
|
|
110
|
+
let stopped = false;
|
|
111
|
+
let feedClient: Awaited<ReturnType<typeof connectPriceFeed>> | null = null;
|
|
112
|
+
|
|
113
|
+
const onLaunch = (event: PriceFeedLaunch) => {
|
|
114
|
+
if (!venues.includes(event.venue)) return;
|
|
115
|
+
if (event.isMayhemMode === true) {
|
|
116
|
+
ignoredMayhem += 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
states.set(event.mint, {
|
|
120
|
+
mint: event.mint,
|
|
121
|
+
venue: event.venue,
|
|
122
|
+
createdAtMs: event.atMs,
|
|
123
|
+
lastSeenAtMs: event.atMs,
|
|
124
|
+
qualified: false,
|
|
125
|
+
athMarketCapUsd: null,
|
|
126
|
+
lastNotifiedAthUsd: null,
|
|
127
|
+
});
|
|
128
|
+
launches += 1;
|
|
129
|
+
if (showNew)
|
|
130
|
+
report("new launch", {
|
|
131
|
+
at: new Date(event.atMs).toISOString(),
|
|
132
|
+
venue: event.venue,
|
|
133
|
+
mint: event.mint,
|
|
134
|
+
symbol: event.symbol,
|
|
135
|
+
name: event.name,
|
|
136
|
+
supplyUi: event.supplyUi,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onPrice = (event: PriceFeedPrice) => {
|
|
141
|
+
let state = states.get(event.mint);
|
|
142
|
+
if (!state) return;
|
|
143
|
+
if (!venues.includes(state.venue)) return;
|
|
144
|
+
state.lastSeenAtMs = event.atMs;
|
|
145
|
+
prices += 1;
|
|
146
|
+
if (showPrices)
|
|
147
|
+
report("price", {
|
|
148
|
+
at: new Date(event.atMs).toISOString(),
|
|
149
|
+
venue: event.venue,
|
|
150
|
+
mint: event.mint,
|
|
151
|
+
marketCapUsd: event.marketCapUsd,
|
|
152
|
+
priceUsd: event.priceUsd,
|
|
153
|
+
priceSol: event.priceSol,
|
|
154
|
+
source: event.source,
|
|
155
|
+
});
|
|
156
|
+
const mcap = event.marketCapUsd;
|
|
157
|
+
if (!(mcap != null && Number.isFinite(mcap) && mcap > 0)) return;
|
|
158
|
+
state.athMarketCapUsd = Math.max(state.athMarketCapUsd ?? 0, mcap);
|
|
159
|
+
if (!state.qualified) {
|
|
160
|
+
if (mcap < minMarketCapUsd) return;
|
|
161
|
+
state.qualified = true;
|
|
162
|
+
state.lastNotifiedAthUsd = mcap;
|
|
163
|
+
if (state.venue === "raydium-launchlab")
|
|
164
|
+
feedClient?.subscribeMints([event.mint]);
|
|
165
|
+
qualified += 1;
|
|
166
|
+
thresholdNotifications += 1;
|
|
167
|
+
const nextAthNotifyUsd = mcap * (1 + athStepPct / 100);
|
|
168
|
+
if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
|
|
169
|
+
report("market cap threshold", {
|
|
170
|
+
at: new Date(event.atMs).toISOString(),
|
|
171
|
+
venue: state.venue,
|
|
172
|
+
mint: event.mint,
|
|
173
|
+
marketCapUsd: mcap,
|
|
174
|
+
thresholdMarketCapUsd: minMarketCapUsd,
|
|
175
|
+
nextAthNotifyUsd,
|
|
176
|
+
athStepPct,
|
|
177
|
+
priceUsd: event.priceUsd,
|
|
178
|
+
priceSol: event.priceSol,
|
|
179
|
+
signature: event.signature,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const previous = state.lastNotifiedAthUsd;
|
|
184
|
+
if (!(previous != null && previous > 0)) {
|
|
185
|
+
state.lastNotifiedAthUsd = mcap;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const target = previous * (1 + athStepPct / 100);
|
|
189
|
+
if (mcap + 1e-12 < target) return;
|
|
190
|
+
state.lastNotifiedAthUsd = mcap;
|
|
191
|
+
athNotifications += 1;
|
|
192
|
+
const increasePct = (mcap / previous - 1) * 100;
|
|
193
|
+
const nextAthNotifyUsd = mcap * (1 + athStepPct / 100);
|
|
194
|
+
if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
|
|
195
|
+
report("new ath", {
|
|
196
|
+
at: new Date(event.atMs).toISOString(),
|
|
197
|
+
venue: state.venue,
|
|
198
|
+
mint: event.mint,
|
|
199
|
+
marketCapUsd: mcap,
|
|
200
|
+
previousNotifiedAthUsd: previous,
|
|
201
|
+
nextAthNotifyUsd,
|
|
202
|
+
athStepPct,
|
|
203
|
+
increasePct,
|
|
204
|
+
priceUsd: event.priceUsd,
|
|
205
|
+
priceSol: event.priceSol,
|
|
206
|
+
signature: event.signature,
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
feedClient = await connectPriceFeed({
|
|
212
|
+
url,
|
|
213
|
+
subscribe: { op: "subscribe", launches: true, allPrices: true },
|
|
214
|
+
signal: controller.signal,
|
|
215
|
+
onStatus(event, data) {
|
|
216
|
+
if (event === "connected") feedConnected = true;
|
|
217
|
+
if (event === "connect-error") feedConnected = false;
|
|
218
|
+
report(`feed ${event}`, data ?? {});
|
|
219
|
+
},
|
|
220
|
+
onMessage(message) {
|
|
221
|
+
if (message.type === "launch") onLaunch(message);
|
|
222
|
+
if (message.type === "price") onPrice(message);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const stop = () => {
|
|
227
|
+
stopped = true;
|
|
228
|
+
controller.abort();
|
|
229
|
+
};
|
|
230
|
+
process.once("SIGINT", stop);
|
|
231
|
+
process.once("SIGTERM", stop);
|
|
232
|
+
report("ready", {
|
|
233
|
+
feed: url,
|
|
234
|
+
venues,
|
|
235
|
+
minMarketCapUsd,
|
|
236
|
+
athStepPct,
|
|
237
|
+
excludeMayhem: true,
|
|
238
|
+
trackTtlMs,
|
|
239
|
+
upstreamSubscriptions: 0,
|
|
240
|
+
});
|
|
241
|
+
const heartbeat = setInterval(
|
|
242
|
+
() => {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
for (const [mint, state] of states) {
|
|
245
|
+
if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs)
|
|
246
|
+
states.delete(mint);
|
|
247
|
+
}
|
|
248
|
+
report("heartbeat", {
|
|
249
|
+
feedConnected,
|
|
250
|
+
launches,
|
|
251
|
+
ignoredMayhem,
|
|
252
|
+
tracked: states.size,
|
|
253
|
+
qualified,
|
|
254
|
+
prices,
|
|
255
|
+
thresholdNotifications,
|
|
256
|
+
athNotifications,
|
|
257
|
+
upstreamSubscriptions: 0,
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
while (!stopped) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
265
|
+
} finally {
|
|
266
|
+
clearInterval(heartbeat);
|
|
267
|
+
controller.abort();
|
|
268
|
+
feedClient?.close();
|
|
269
|
+
process.removeListener("SIGINT", stop);
|
|
270
|
+
process.removeListener("SIGTERM", stop);
|
|
271
|
+
report("stopped", {
|
|
272
|
+
launches,
|
|
273
|
+
prices,
|
|
274
|
+
thresholdNotifications,
|
|
275
|
+
athNotifications,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|