candle-launch-bot 0.0.0-stage → 0.1.0

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.

Potentially problematic release.


This version of candle-launch-bot might be problematic. Click here for more details.

Files changed (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +400 -2
  4. package/bin/candle-launch-bot +7 -0
  5. package/dist/alerts.d.ts +28 -0
  6. package/dist/alerts.js +82 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.js +321 -0
  9. package/dist/config.d.ts +88 -0
  10. package/dist/config.js +244 -0
  11. package/dist/detect/detector.d.ts +111 -0
  12. package/dist/detect/detector.js +348 -0
  13. package/dist/engine.d.ts +103 -0
  14. package/dist/engine.js +585 -0
  15. package/dist/exec/executor.d.ts +149 -0
  16. package/dist/exec/executor.js +859 -0
  17. package/dist/exec/fills.d.ts +25 -0
  18. package/dist/exec/fills.js +53 -0
  19. package/dist/exec/trader.d.ts +109 -0
  20. package/dist/exec/trader.js +310 -0
  21. package/dist/filters.d.ts +26 -0
  22. package/dist/filters.js +92 -0
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +2 -0
  25. package/dist/init.d.ts +23 -0
  26. package/dist/init.js +192 -0
  27. package/dist/ledger.d.ts +189 -0
  28. package/dist/ledger.js +243 -0
  29. package/dist/log.d.ts +22 -0
  30. package/dist/log.js +40 -0
  31. package/dist/market/curve.d.ts +80 -0
  32. package/dist/market/curve.js +168 -0
  33. package/dist/market/reader.d.ts +37 -0
  34. package/dist/market/reader.js +106 -0
  35. package/dist/redact.d.ts +20 -0
  36. package/dist/redact.js +111 -0
  37. package/dist/solana/base58.d.ts +4 -0
  38. package/dist/solana/base58.js +45 -0
  39. package/dist/solana/logs-ws.d.ts +25 -0
  40. package/dist/solana/logs-ws.js +116 -0
  41. package/dist/solana/pda.d.ts +12 -0
  42. package/dist/solana/pda.js +70 -0
  43. package/dist/solana/rpc.d.ts +29 -0
  44. package/dist/solana/rpc.js +96 -0
  45. package/dist/strategies/hold.d.ts +3 -0
  46. package/dist/strategies/hold.js +6 -0
  47. package/dist/strategies/ladder.d.ts +8 -0
  48. package/dist/strategies/ladder.js +51 -0
  49. package/dist/strategies/load.d.ts +5 -0
  50. package/dist/strategies/load.js +44 -0
  51. package/dist/strategies/preGrad.d.ts +10 -0
  52. package/dist/strategies/preGrad.js +32 -0
  53. package/dist/strategies/time.d.ts +6 -0
  54. package/dist/strategies/time.js +16 -0
  55. package/dist/strategies/tpsl.d.ts +8 -0
  56. package/dist/strategies/tpsl.js +30 -0
  57. package/dist/strategies/types.d.ts +55 -0
  58. package/dist/strategies/types.js +31 -0
  59. package/examples/dry.json +32 -0
  60. package/examples/live-embedded.json +14 -0
  61. package/examples/live-linked.json +15 -0
  62. package/examples/strategies/breakeven-stop.js +22 -0
  63. package/examples/strategies/stale-coin.js +18 -0
  64. package/examples/strategies/timeboxed-tp.js +18 -0
  65. package/package.json +60 -4
  66. package/skills/candle-launch-bot/SKILL.md +71 -0
package/dist/engine.js ADDED
@@ -0,0 +1,585 @@
1
+ /**
2
+ * The engine loop. Each cycle:
3
+ * controls (STOP, PAUSE_BUYS, manual sell requests) -> new launches through the filters -> buys ->
4
+ * one executor sweep -> market reads for open positions -> exit strategy -> sells -> status.json
5
+ * Nothing in a cycle can throw out of the loop; the control files are read first and never block.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { Executor } from "./exec/executor.js";
11
+ import { evaluate, lamportsOf } from "./filters.js";
12
+ import { decodeCandleCurve } from "./market/curve.js";
13
+ import { readMarkets } from "./market/reader.js";
14
+ import { redactDeep } from "./redact.js";
15
+ import { candleCurve } from "./solana/pda.js";
16
+ import { tokenBalance } from "./solana/rpc.js";
17
+ /** Most time one cycle spends before the rest of its work waits for the next one (exits always run). */
18
+ export const CYCLE_BUDGET_MS = 20_000;
19
+ /** Candle's market record while filtering a launch: a slow answer must not hold the loop. */
20
+ export const MARKET_READ_TIMEOUT_MS = 3_000;
21
+ /** A DAMM v2 pool this close to its floor has no liquidity left to sell into. */
22
+ /** How often the wallet's balance of each held coin is compared with what this bot booked. */
23
+ export const SURPLUS_CHECK_MS = 600_000;
24
+ /** Coins whose position ended this recently are still checked for a surplus. */
25
+ export const SURPLUS_TAIL_MS = 3_600_000;
26
+ export const FLOOR_ROOM_MIN = 0.005;
27
+ const DEFAULT_DECIMALS = 6;
28
+ /** Read a control file without ever blocking or throwing: regular files only, non-blocking open, bounded read. */
29
+ export function readControl(path) {
30
+ let fd = null;
31
+ try {
32
+ fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
33
+ if (!fstatSync(fd).isFile())
34
+ return null;
35
+ const buf = Buffer.alloc(4096);
36
+ const n = readSync(fd, buf, 0, buf.length, 0);
37
+ return buf.subarray(0, n).toString("utf8");
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ finally {
43
+ if (fd !== null) {
44
+ try {
45
+ closeSync(fd);
46
+ }
47
+ catch {
48
+ /* closed */
49
+ }
50
+ }
51
+ }
52
+ }
53
+ export class Engine {
54
+ d;
55
+ now;
56
+ pending = new Map();
57
+ exitHoldUntil = new Map();
58
+ /** When each position's exits were first held for want of a sell route (cleared once a route is back). */
59
+ noRouteSince = new Map();
60
+ /** Next surplus check per coin. */
61
+ surplusAt = new Map();
62
+ surplusSeen = new Set();
63
+ markets = new Map();
64
+ stopRequested = false;
65
+ cycles = 0;
66
+ lastCycleMs = 0;
67
+ stats = { launches: 0, buys: 0, skips: 0, sells: 0 };
68
+ constructor(d) {
69
+ this.d = d;
70
+ this.now = d.now ?? Date.now;
71
+ mkdirSync(join(d.cfg.stateDir, "requests"), { recursive: true });
72
+ }
73
+ get stopping() {
74
+ return this.stopRequested;
75
+ }
76
+ requestStop() {
77
+ this.stopRequested = true;
78
+ }
79
+ paused() {
80
+ return readControl(join(this.d.cfg.stateDir, "PAUSE_BUYS")) !== null;
81
+ }
82
+ /** One full cycle. Returns false when STOP was seen (the caller exits cleanly). */
83
+ /** STOP file or SIGTERM/SIGINT: checked before every step and between orders. */
84
+ shouldStop = () => {
85
+ if (!this.stopRequested && readControl(join(this.d.cfg.stateDir, "STOP")) !== null) {
86
+ this.d.log.info("stop_file_seen");
87
+ this.stopRequested = true;
88
+ }
89
+ return this.stopRequested;
90
+ };
91
+ /** Each cycle has a time budget; work left over waits for the next cycle. */
92
+ overBudget(t0) {
93
+ return this.now() - t0 > CYCLE_BUDGET_MS;
94
+ }
95
+ async cycle() {
96
+ const t0 = this.now();
97
+ const sweep = (side, reconcileOnly = false) => this.d.executor.sweep(5, { side, reconcileOnly, budgetMs: Math.max(1_000, CYCLE_BUDGET_MS - (this.now() - t0)), shouldStop: this.shouldStop });
98
+ // Exits first: a stop-loss never waits behind new entries.
99
+ const steps = [
100
+ ["requests", () => this.manualRequests()],
101
+ ["sells", () => sweep("sell")],
102
+ ["exits", () => this.exits()],
103
+ ["sells2", () => sweep("sell")],
104
+ // Buys that may already have executed get their own slot: settling them never waits behind a slow sell side.
105
+ ["settle-buys", () => sweep("buy", true)],
106
+ ["entries", () => this.entries(t0)],
107
+ ["orders", () => sweep()],
108
+ ["nag", async () => this.nag()],
109
+ ["surplus", () => this.surplus()],
110
+ ];
111
+ for (const [name, fn] of steps) {
112
+ if (this.shouldStop()) {
113
+ this.writeStatus();
114
+ return false;
115
+ }
116
+ // Past the budget only the exit steps still run.
117
+ if (this.overBudget(t0) && !["sells", "exits", "sells2", "settle-buys", "nag"].includes(name))
118
+ continue;
119
+ await this.guard(name, fn);
120
+ }
121
+ if (this.shouldStop()) {
122
+ this.writeStatus();
123
+ return false;
124
+ }
125
+ this.cycles++;
126
+ this.lastCycleMs = this.now() - t0;
127
+ this.writeStatus();
128
+ return true;
129
+ }
130
+ async guard(what, fn) {
131
+ try {
132
+ await fn();
133
+ }
134
+ catch (e) {
135
+ this.d.log.error("cycle_step_failed", { step: what, error: String(e?.stack ?? e) });
136
+ }
137
+ }
138
+ /** Conditions that need a human re-announce (alerts.renotifyMs) until they clear. */
139
+ nag() {
140
+ for (const p of this.mine("exit_stuck"))
141
+ this.d.alerter.raise(`position:${p.id}:exit_stuck`, `position ${p.id} ${p.mint}: exits refused ${p.exit_orders_failed} times; retried every ${Math.round(this.d.cfg.retry.stuckRetryMs / 60000)} min, or run \`sell\` by hand`);
142
+ for (const p of this.mine("opening", "open", "exit_stuck")) {
143
+ const o = this.d.ledger.openOrderFor(p.id);
144
+ if (o && o.tx_read_failures >= 6)
145
+ this.d.alerter.raise(`order:${o.id}:qty_unknown`, `order ${o.id} ${o.side} ${p.mint} landed but its fill is still unread`);
146
+ }
147
+ }
148
+ /**
149
+ * The wallet holding more of a coin than this bot booked (a second execution that landed after the first was booked,
150
+ * or tokens from elsewhere): alerted, never adopted. Checked per coin every SURPLUS_CHECK_MS, while no order of the
151
+ * coin is in flight.
152
+ */
153
+ async surplus() {
154
+ if (this.dry)
155
+ return;
156
+ const held = this.mine("opening", "open", "exit_stuck");
157
+ // Also coins whose position ended within the last hour: a late second execution can outlive the position.
158
+ const recent = this.mine("closed", "failed").filter((p) => p.closed_at !== null && this.now() - p.closed_at < SURPLUS_TAIL_MS);
159
+ const mints = [...new Set([...held, ...recent].map((p) => p.mint))];
160
+ for (const mint of mints) {
161
+ if (this.shouldStop())
162
+ return;
163
+ if ((this.surplusAt.get(mint) ?? 0) > this.now())
164
+ continue;
165
+ const ps = held.filter((p) => p.mint === mint);
166
+ if (ps.some((p) => p.status === "opening" || this.d.ledger.openOrderFor(p.id)))
167
+ continue;
168
+ this.surplusAt.set(mint, this.now() + SURPLUS_CHECK_MS);
169
+ const bal = await tokenBalance(this.d.rpc, this.d.cfg.wallet, mint);
170
+ if (bal === null)
171
+ continue;
172
+ const booked = ps.reduce((t, p) => t + BigInt(p.remaining_raw), 0n);
173
+ if (bal <= booked) {
174
+ this.surplusSeen.delete(mint);
175
+ continue;
176
+ }
177
+ // Two readings in a row (a minute apart), so one lagging read after a sell is not an alarm.
178
+ if (!this.surplusSeen.has(mint)) {
179
+ this.surplusSeen.add(mint);
180
+ this.surplusAt.set(mint, this.now() + 60_000);
181
+ continue;
182
+ }
183
+ this.d.alerter.raise(`coin:${mint}:surplus`, `${mint}: the wallet holds ${bal} tokens, ${bal - booked} more than this bot booked${ps.length ? ` (positions ${ps.map((p) => p.id).join(", ")})` : " (no open position)"}; not managed, \`sell\` reaches a failed buy's own`);
184
+ }
185
+ }
186
+ // ---------------------------------------------------------------- entries
187
+ get dry() {
188
+ return this.d.cfg.mode === "dry";
189
+ }
190
+ /** Positions of this run's mode only: a dry run never manages live rows, a live run never dry ones. */
191
+ mine(...statuses) {
192
+ return this.d.ledger.positionsByStatus(...statuses).filter((p) => !!p.dry === this.dry);
193
+ }
194
+ openCount() {
195
+ return this.mine("opening", "open", "exit_stuck").length;
196
+ }
197
+ async entries(t0 = this.now()) {
198
+ for (const l of this.d.detector.drain()) {
199
+ this.stats.launches++;
200
+ const s = this.d.detector.sightingsFor(l.mint)[l.source];
201
+ this.d.log.info("launch_detected", { mint: l.mint, source: l.source, symbol: l.symbol, mode: l.launchMode, latencyMs: s?.latencyMs ?? null, signature: l.signature });
202
+ this.pending.set(l.mint, { launch: l, firstSeenMs: this.now(), lastReason: "", marketTried: 0 });
203
+ }
204
+ for (const [mint, p] of [...this.pending]) {
205
+ if (this.shouldStop() || this.overBudget(t0))
206
+ break;
207
+ const l = this.d.detector.get(mint) ?? p.launch;
208
+ const v = await this.decide(l, p);
209
+ const sights = this.d.detector.sightingsFor(mint);
210
+ if (v.verdict === "wait") {
211
+ if (v.reason !== p.lastReason)
212
+ this.d.log.info("launch_wait", { mint, reason: v.reason });
213
+ p.lastReason = v.reason;
214
+ continue;
215
+ }
216
+ this.pending.delete(mint);
217
+ this.d.ledger.recordLaunch({
218
+ mint, chain: l.chain, first_source: l.source, first_seen_at: l.detectedAtMs, launched_at: l.launchedAtMs,
219
+ api_seen_at: sights.api?.atMs ?? null, chain_seen_at: sights.chain?.atMs ?? null,
220
+ api_latency_ms: sights.api?.latencyMs ?? null, chain_latency_ms: sights.chain?.latencyMs ?? null,
221
+ symbol: l.symbol ?? null, name: l.name ?? null, creator: l.creator ?? l.signer ?? null, launch_mode: l.launchMode ?? null,
222
+ decision: v.verdict, reason: v.verdict === "skip" ? v.reason : `impact ${v.impactBps} bps`,
223
+ });
224
+ if (v.verdict === "skip") {
225
+ this.stats.skips++;
226
+ this.d.log.info("launch_skip", { mint, symbol: l.symbol, reason: v.reason });
227
+ continue;
228
+ }
229
+ this.buy(l, v.impactBps, v.expectedOutRaw);
230
+ }
231
+ // Keep the latency record current for launches decided before the slower source saw them.
232
+ for (const row of this.d.ledger.recentLaunches(10)) {
233
+ const s = this.d.detector.sightingsFor(row.mint);
234
+ if ((s.api && (row.api_seen_at === null || row.api_latency_ms !== s.api.latencyMs)) || (s.chain && row.chain_seen_at === null)) {
235
+ this.d.ledger.recordLaunch({ ...row, api_seen_at: s.api?.atMs ?? null, chain_seen_at: s.chain?.atMs ?? null, api_latency_ms: s.api?.latencyMs ?? null, chain_latency_ms: s.chain?.latencyMs ?? null, decision: null, reason: null });
236
+ }
237
+ }
238
+ }
239
+ async decide(l, p) {
240
+ const caps = { open: this.openCount(), entriesForMint: this.d.ledger.entriesForMint(l.mint, this.dry), lifetimeLamports: this.d.ledger.lifetimeBuyLamports(this.d.cfg.mode === "dry"), paused: this.paused() };
241
+ let v = evaluate(this.d.cfg, l, null, caps, this.now());
242
+ if (v.verdict === "wait" && /mode unknown|name unknown|symbol unknown|creator unknown/.test(v.reason) && this.d.markets && p.marketTried < 10) {
243
+ p.marketTried++;
244
+ try {
245
+ const m = await Promise.race([
246
+ this.d.markets.getMarket("solana", l.mint),
247
+ new Promise((_r, rej) => setTimeout(() => rej(new Error("market read timeout")), MARKET_READ_TIMEOUT_MS)),
248
+ ]);
249
+ if (m?.tier && !l.launchMode)
250
+ l.launchMode = String(m.tier);
251
+ if (m?.metadata?.name && l.name === undefined)
252
+ l.name = String(m.metadata.name);
253
+ if (m?.metadata?.symbol && l.symbol === undefined)
254
+ l.symbol = String(m.metadata.symbol);
255
+ if (typeof m?.decimals === "number" && l.decimals === undefined)
256
+ l.decimals = m.decimals;
257
+ }
258
+ catch {
259
+ /* not indexed yet: the next cycle asks again */
260
+ }
261
+ v = evaluate(this.d.cfg, l, null, caps, this.now());
262
+ }
263
+ if (v.verdict !== "wait" || v.reason !== "curve not readable yet")
264
+ return v;
265
+ let curve = null;
266
+ try {
267
+ const r = await this.d.rpc.call("getAccountInfo", [l.curve ?? candleCurve(l.mint), { encoding: "base64", commitment: "confirmed" }]);
268
+ curve = r?.value ? decodeCandleCurve(r.value) : null;
269
+ }
270
+ catch (e) {
271
+ curve = /owner|layout/.test(String(e.message)) ? "unreadable" : null;
272
+ }
273
+ return evaluate(this.d.cfg, l, curve, caps, this.now());
274
+ }
275
+ buy(l, impactBps, expectedOutRaw) {
276
+ const now = this.now();
277
+ const dry = this.d.cfg.mode === "dry";
278
+ const lamports = lamportsOf(this.d.cfg.buy.sol);
279
+ const id = this.d.ledger.tx(() => {
280
+ const pid = this.d.ledger.createPosition({ mint: l.mint, chain: l.chain, dry, now, decimals: l.decimals ?? DEFAULT_DECIMALS, strategy: this.d.strategy.name, launch: l });
281
+ // The buy is only good while the filter's decision is: no attempt is built past the coin's max age.
282
+ const deadlineAt = (l.launchedAtMs ?? now) + this.d.cfg.filters.maxAgeSec * 1000;
283
+ this.d.ledger.createOrder({ positionId: pid, side: "buy", clientTradeId: `clb-${randomUUID()}`, amountRaw: lamports, slippageBps: this.d.cfg.buy.maxSlippageBps, maxAttempts: this.d.cfg.retry.maxAttempts, now, reason: `entry, impact ${impactBps} bps`, deadlineAt, expectedOutRaw });
284
+ return pid;
285
+ });
286
+ this.stats.buys++;
287
+ this.d.log.info(dry ? "dry_buy" : "buy_order", { position: id, mint: l.mint, symbol: l.symbol, sol: this.d.cfg.buy.sol, impactBps, ageMs: l.launchedAtMs ? now - l.launchedAtMs : null });
288
+ }
289
+ // ---------------------------------------------------------------- exits
290
+ view(p, m) {
291
+ const qty = BigInt(p.qty_raw);
292
+ const rem = BigInt(p.remaining_raw);
293
+ // Tokens written off for a wallet shortfall were never sold: they leave both sides of soldFraction.
294
+ const base = qty - BigInt(p.written_down_raw ?? "0");
295
+ const entry = qty > 0n ? Number(BigInt(p.cost_lamports)) / 1e9 / (Number(qty) / 10 ** p.decimals) : 0;
296
+ return {
297
+ mint: p.mint,
298
+ openedAtMs: p.opened_at,
299
+ qtyRaw: qty,
300
+ remainingRaw: rem,
301
+ decimals: p.decimals,
302
+ entryPriceSol: entry,
303
+ peakPriceSol: Math.max(p.peak_price, m.priceSol ?? 0),
304
+ soldFraction: base > 0n ? Math.max(0, Number(base - rem) / Number(base)) : 0,
305
+ };
306
+ }
307
+ async exits() {
308
+ const open = this.mine("open", "exit_stuck");
309
+ if (!open.length)
310
+ return;
311
+ this.markets = await readMarkets(this.d.rpc, open.map((p) => ({ mint: p.mint, decimals: p.decimals })), this.now());
312
+ for (const p of open) {
313
+ const m = this.markets.get(p.mint);
314
+ if (!m)
315
+ continue;
316
+ if (m.priceSol !== null)
317
+ this.d.ledger.updatePosition(p.id, { last_price: m.priceSol, peak_price: Math.max(p.peak_price, m.priceSol) });
318
+ if (this.shouldStop())
319
+ return;
320
+ // exit_stuck: refused repeatedly. Not permanent: one more try on the slow clock (the alert keeps nagging).
321
+ if (p.status === "exit_stuck") {
322
+ const lastSell = this.d.ledger.ordersFor(p.id).filter((o) => o.side === "sell").pop();
323
+ if (lastSell && this.now() < lastSell.updated_at + this.d.cfg.retry.stuckRetryMs)
324
+ continue;
325
+ }
326
+ if (this.d.ledger.openOrderFor(p.id))
327
+ continue; // one exit in flight at a time
328
+ if ((this.exitHoldUntil.get(p.id) ?? 0) > this.now())
329
+ continue;
330
+ // No sell route right now: migrating to its pool, unreadable, or a pool at its floor. Hold; don't burn exits.
331
+ if (m.problem || m.priceSol === null || (m.venue === "damm" && m.roomAboveFloor !== null && m.roomAboveFloor < FLOOR_ROOM_MIN)) {
332
+ const why = m.problem ?? (m.priceSol === null ? "no price" : "pool at its floor");
333
+ if (this.d.ledger.alert(`position:${p.id}:no_route:${why}`, `position ${p.id} ${p.mint}: exits held (${why})`, this.now(), 3_600_000))
334
+ this.d.log.info("exit_held", { position: p.id, mint: p.mint, why });
335
+ // A short hold is normal (migration takes a few slots); one that lasts reaches the alert sinks, renotified.
336
+ const since = this.noRouteSince.get(p.id) ?? this.now();
337
+ if (!this.noRouteSince.has(p.id))
338
+ this.noRouteSince.set(p.id, since);
339
+ const held = this.now() - since;
340
+ if (held >= this.d.cfg.alerts.holdAlertMs)
341
+ this.d.alerter.raise(`position:${p.id}:no_route`, `position ${p.id} ${p.mint}: no sell route for ${Math.round(held / 60_000)} min (${why}); exits held`);
342
+ continue;
343
+ }
344
+ this.noRouteSince.delete(p.id);
345
+ // After failed exit orders (refused or out of attempts), the next waits on the position's own backoff clock.
346
+ const sells = this.d.ledger.ordersFor(p.id).filter((o) => o.side === "sell");
347
+ let streak = 0;
348
+ for (let i = sells.length - 1; i >= 0 && ["failed", "rejected", "reverted"].includes(sells[i].status); i--)
349
+ streak++;
350
+ if (streak > 0 && p.status !== "exit_stuck" && this.now() < sells[sells.length - 1].updated_at + this.d.executor.backoffMs(streak + 1))
351
+ continue;
352
+ let decision;
353
+ try {
354
+ decision = this.d.strategy.onTick(this.view(p, m), m);
355
+ }
356
+ catch (e) {
357
+ this.d.log.error("strategy_error", { position: p.id, error: String(e?.message ?? e) });
358
+ continue;
359
+ }
360
+ if (decision.action !== "sell")
361
+ continue;
362
+ await this.placeSell(p, decision.fraction, decision.reason, false);
363
+ }
364
+ }
365
+ /** Create a sell order clamped to the on-chain balance. Returns the order id, or null when refused. */
366
+ async placeSell(p, fraction, reason, manual) {
367
+ p = this.d.ledger.position(p.id) ?? p;
368
+ if (!(fraction > 0 && fraction <= 1))
369
+ throw new Error("fraction must be in (0, 1]");
370
+ let remaining = BigInt(p.remaining_raw);
371
+ if (remaining <= 0n)
372
+ return null;
373
+ const part = (x) => (fraction >= 1 ? x : (x * BigInt(Math.round(fraction * 1e6))) / 1000000n);
374
+ if (part(remaining) <= 0n)
375
+ return null;
376
+ if (!p.dry) {
377
+ const bal = await tokenBalance(this.d.rpc, this.d.cfg.wallet, p.mint);
378
+ const refuse = (why) => {
379
+ // Zero or unreadable: the tokens are gone or this is the wrong account. Never sell for nothing.
380
+ this.exitHoldUntil.set(p.id, this.now() + 60_000);
381
+ const key = `position:${p.id}:balance:${why}`;
382
+ this.d.alerter.raise(key, `sell refused for position ${p.id} ${p.mint}: wallet balance ${why}`);
383
+ return null;
384
+ };
385
+ if (bal === null)
386
+ return refuse("unreadable");
387
+ // Other open positions of this bot in the same coin own part of the balance.
388
+ const others = this.mine("open", "exit_stuck", "opening").filter((x) => x.mint === p.mint && x.id !== p.id).reduce((s, x) => s + BigInt(x.remaining_raw), 0n);
389
+ const available = bal - others;
390
+ if (available <= 0n)
391
+ return refuse("zero");
392
+ if (available < remaining) {
393
+ // The wallet holds less than the ledger says: write the position down to what is really there (only ever
394
+ // down), so a sell of it can close the position instead of leaving a phantom remainder forever.
395
+ this.d.ledger.updatePosition(p.id, { remaining_raw: available.toString(), written_down_raw: (BigInt(p.written_down_raw ?? "0") + remaining - available).toString(), note: `shortfall: ledger ${remaining}, wallet ${available}` });
396
+ const key = `position:${p.id}:shortfall`;
397
+ this.d.alerter.raise(key, `position ${p.id} ${p.mint}: wallet holds ${available} of ${remaining} booked`);
398
+ this.d.log.warn("sell_clamped", { position: p.id, mint: p.mint, bookedRaw: remaining, balanceRaw: available });
399
+ remaining = available;
400
+ }
401
+ }
402
+ const amount = part(remaining);
403
+ if (amount <= 0n)
404
+ return null;
405
+ const now = this.now();
406
+ const id = this.d.ledger.createOrder({ positionId: p.id, side: "sell", clientTradeId: `cls-${randomUUID()}`, amountRaw: amount, slippageBps: this.d.cfg.sell.maxSlippageBps, maxAttempts: this.d.cfg.retry.maxAttempts, now, reason, manual });
407
+ this.stats.sells++;
408
+ this.d.log.info(p.dry ? "dry_sell" : "sell_order", { position: p.id, mint: p.mint, amountRaw: amount, fraction, reason, manual });
409
+ return id;
410
+ }
411
+ // ---------------------------------------------------------------- manual requests (from the `sell` command)
412
+ async manualRequests() {
413
+ const dir = join(this.d.cfg.stateDir, "requests");
414
+ let names = [];
415
+ try {
416
+ names = readdirSync(dir).filter((n) => /^sell-[A-Za-z0-9-]+\.json$/.test(n));
417
+ }
418
+ catch {
419
+ return;
420
+ }
421
+ for (const n of names) {
422
+ const path = join(dir, n);
423
+ const text = readControl(path);
424
+ try {
425
+ unlinkSync(path);
426
+ }
427
+ catch {
428
+ /* gone */
429
+ }
430
+ if (!text)
431
+ continue;
432
+ try {
433
+ const req = JSON.parse(text);
434
+ const r = await this.manualSell(req.mint, req.fraction);
435
+ this.d.log.info("manual_sell_request", { mint: req.mint, fraction: req.fraction, result: r });
436
+ }
437
+ catch (e) {
438
+ this.d.log.warn("manual_sell_request_bad", { file: n, error: String(e.message) });
439
+ }
440
+ }
441
+ }
442
+ /** The `sell` command's path: same clamp, same executor. Moves an exit_stuck position back to open. */
443
+ async manualSell(mint, fraction) {
444
+ let p = this.mine("open", "exit_stuck").find((x) => x.mint === mint);
445
+ if (!p) {
446
+ const r = await this.reopenFailedBuy(mint);
447
+ if (typeof r === "string")
448
+ return r;
449
+ p = r;
450
+ }
451
+ if (this.d.ledger.openOrderFor(p.id))
452
+ return "an exit order is already in flight";
453
+ // exit_stuck is cleared only when this manual sell fills (the executor does that), not before.
454
+ const id = await this.placeSell(p, fraction, "manual", true);
455
+ return id === null ? "refused (see alerts)" : `order ${id}`;
456
+ }
457
+ /**
458
+ * `sell` on a coin whose buy ended failed or rejected while the wallet holds its tokens (a buy that landed after the
459
+ * bot gave up, or that it could not prove): reopen that position with the wallet's unexplained tokens (qty from the
460
+ * wallet, cost the order's ticket) so the sell can run. Only buys that may have executed qualify. Alerted.
461
+ */
462
+ async reopenFailedBuy(mint) {
463
+ const dry = this.d.cfg.mode === "dry";
464
+ const cand = this.d.ledger.positionsByStatus("failed").filter((x) => x.mint === mint && !!x.dry === dry).pop();
465
+ const buy = cand ? this.d.ledger.ordersFor(cand.id).find((o) => o.side === "buy") : undefined;
466
+ // Only a buy that ended failed or rejected and may have run: a revert delivered nothing, a filled buy was booked.
467
+ if (!cand || !buy || !["failed", "rejected"].includes(buy.status) || !(buy.ever_signed === 1 || buy.signature) || BigInt(cand.qty_raw) > 0n)
468
+ return "no open position for that mint";
469
+ if (dry)
470
+ return "no open position for that mint";
471
+ const r = await this.d.executor.reopenable(buy.id);
472
+ if ("why" in r)
473
+ return `no open position for that mint (${r.why})`;
474
+ this.d.ledger.tx(() => {
475
+ this.d.ledger.updatePosition(cand.id, { status: "open", closed_at: null, qty_raw: r.amount.toString(), remaining_raw: r.amount.toString(), qty_source: r.signatures.length ? "onchain" : "wallet", cost_lamports: r.costLamports.toString(), note: `reopened by sell: ${r.amount} tokens received since the buy (ended ${buy.status})` });
476
+ // Our transactions, once found, are booked to the buy so no other scan claims them.
477
+ if (r.signatures.length)
478
+ this.d.ledger.updateOrder(buy.id, { status: "filled", signature: r.signatures[0], extra_signatures: r.signatures.length > 1 ? JSON.stringify(r.signatures.slice(1)) : null });
479
+ });
480
+ this.d.alerter.raise(`position:${cand.id}:reopened`, `position ${cand.id} ${mint}: buy ended ${buy.status}; reopened with ${r.amount} tokens received since the order (${r.signatures.length ? `our tx ${r.signatures.join(", ")}` : "no tx of ours found; capped, cost the ticket"}) for the manual sell`);
481
+ return this.d.ledger.position(cand.id);
482
+ }
483
+ // ---------------------------------------------------------------- status.json
484
+ statusObject() {
485
+ const now = this.now();
486
+ const dry = this.d.cfg.mode === "dry";
487
+ const all = this.d.ledger.allPositions().filter((p) => !!p.dry === dry);
488
+ let spent = 0n, proceeds = 0n, fees = 0n, rent = 0n, openValue = 0;
489
+ const open = [];
490
+ for (const p of all) {
491
+ spent += BigInt(p.cost_lamports);
492
+ proceeds += BigInt(p.proceeds_lamports);
493
+ fees += BigInt(p.network_fees_lamports);
494
+ rent += BigInt(p.rent_lamports ?? "0");
495
+ if (p.status === "open" || p.status === "exit_stuck" || p.status === "opening") {
496
+ const m = this.markets.get(p.mint);
497
+ const price = m?.priceSol ?? p.last_price ?? null;
498
+ const remaining = BigInt(p.remaining_raw);
499
+ const value = price === null ? null : (Number(remaining) / 10 ** p.decimals) * price;
500
+ if (value !== null)
501
+ openValue += value;
502
+ const qty = BigInt(p.qty_raw);
503
+ const entry = qty > 0n ? Number(BigInt(p.cost_lamports)) / 1e9 / (Number(qty) / 10 ** p.decimals) : null;
504
+ open.push({ id: p.id, mint: p.mint, status: p.status, openedAt: new Date(p.opened_at).toISOString(), remainingRaw: p.remaining_raw, qtySource: p.qty_source, entryPriceSol: entry, priceSol: price, multiple: entry && price ? price / entry : null, valueSol: value, venue: m?.venue ?? null, progress: m?.progress ?? null, exitOrdersFailed: p.exit_orders_failed });
505
+ }
506
+ }
507
+ const det = this.d.detector.stats;
508
+ const recent = this.d.ledger.recentLaunches(10).map((r) => ({ mint: r.mint, symbol: r.symbol, mode: r.launch_mode, firstSource: r.first_source, apiLatencyMs: r.api_latency_ms, chainLatencyMs: r.chain_latency_ms, decision: r.decision, reason: r.reason }));
509
+ return redactDeep({
510
+ at: new Date(now).toISOString(),
511
+ mode: this.d.cfg.mode,
512
+ wallet: this.d.cfg.wallet,
513
+ strategy: this.d.strategy.name,
514
+ paused: this.paused(),
515
+ stopping: this.stopRequested,
516
+ cycles: this.cycles,
517
+ lastCycleMs: this.lastCycleMs,
518
+ equity: { spentSol: Number(spent) / 1e9, proceedsSol: Number(proceeds) / 1e9, networkFeesSol: Number(fees) / 1e9, rentSol: Number(rent) / 1e9, openValueSol: openValue, pnlSol: (Number(proceeds) - Number(spent) - Number(fees) - Number(rent)) / 1e9 + openValue },
519
+ caps: { open: this.openCount(), maxOpen: this.d.cfg.buy.maxOpen, lifetimeSolUsed: Number(this.d.ledger.lifetimeBuyLamports(dry)) / 1e9, lifetimeSol: this.d.cfg.buy.lifetimeSol },
520
+ queue: this.d.ledger.queueHealth(now, dry),
521
+ alertsSent: this.d.alerter.sent,
522
+ detection: { ...det, pending: [...this.pending.values()].map((p) => ({ mint: p.launch.mint, waiting: p.lastReason })), recent },
523
+ positions: open,
524
+ stats: this.stats,
525
+ });
526
+ }
527
+ writeStatus() {
528
+ try {
529
+ const path = join(this.d.cfg.stateDir, "status.json");
530
+ const tmp = path + ".tmp";
531
+ writeFileSync(tmp, JSON.stringify(this.statusObject(), (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2));
532
+ renameSync(tmp, path);
533
+ }
534
+ catch (e) {
535
+ this.d.log.warn("status_write_failed", { error: String(e.message) });
536
+ }
537
+ }
538
+ }
539
+ /** One process per state dir. */
540
+ export function acquireLock(stateDir) {
541
+ mkdirSync(stateDir, { recursive: true });
542
+ const path = join(stateDir, "lock");
543
+ for (let i = 0; i < 2; i++) {
544
+ try {
545
+ const fd = openSync(path, "wx");
546
+ writeFileSync(fd, String(process.pid));
547
+ closeSync(fd);
548
+ return {
549
+ release() {
550
+ try {
551
+ unlinkSync(path);
552
+ }
553
+ catch {
554
+ /* gone */
555
+ }
556
+ },
557
+ };
558
+ }
559
+ catch {
560
+ const pid = Number(readControl(path) ?? "0");
561
+ let alive = false;
562
+ if (pid > 0) {
563
+ try {
564
+ process.kill(pid, 0);
565
+ alive = true;
566
+ }
567
+ catch (e) {
568
+ alive = e?.code === "EPERM";
569
+ }
570
+ }
571
+ if (alive)
572
+ return null;
573
+ try {
574
+ unlinkSync(path); // stale lock from a crashed run
575
+ }
576
+ catch {
577
+ /* raced */
578
+ }
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+ export function lockHeld(stateDir) {
584
+ return existsSync(join(stateDir, "lock"));
585
+ }