hood-traders 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.
@@ -0,0 +1,1371 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/framework/risk.ts
4
+ var RiskEngine = class {
5
+ constructor(limits) {
6
+ this.limits = limits;
7
+ }
8
+ limits;
9
+ get riskLimits() {
10
+ return this.limits;
11
+ }
12
+ check(ctx) {
13
+ if (ctx.killed) {
14
+ return { ok: false, reason: "kill_switch", detail: "kill switch is tripped \u2014 no new orders" };
15
+ }
16
+ if (ctx.notionalUsd <= 0) {
17
+ return { ok: false, reason: "zero_amount", detail: "order notional is zero or negative" };
18
+ }
19
+ if (ctx.lastTradeAt !== null) {
20
+ const elapsed = (ctx.now - ctx.lastTradeAt) / 1e3;
21
+ if (elapsed < this.limits.cooldownSeconds) {
22
+ const wait = (this.limits.cooldownSeconds - elapsed).toFixed(1);
23
+ return { ok: false, reason: "cooldown", detail: `cooldown active \u2014 ${wait}s until next trade allowed` };
24
+ }
25
+ }
26
+ if (ctx.slippageBps > this.limits.maxSlippageBps) {
27
+ return {
28
+ ok: false,
29
+ reason: "slippage_bound",
30
+ detail: `slippage ${ctx.slippageBps}bps exceeds cap ${this.limits.maxSlippageBps}bps`
31
+ };
32
+ }
33
+ if (ctx.side === "buy") {
34
+ if (ctx.positionUsdAfter > this.limits.maxPositionUsdg) {
35
+ return {
36
+ ok: false,
37
+ reason: "position_cap",
38
+ detail: `position would reach $${ctx.positionUsdAfter.toFixed(2)}, over cap $${this.limits.maxPositionUsdg}`
39
+ };
40
+ }
41
+ if (ctx.spentTodayUsd + ctx.notionalUsd > this.limits.maxDailySpendUsdg) {
42
+ return {
43
+ ok: false,
44
+ reason: "daily_cap",
45
+ detail: `daily spend would reach $${(ctx.spentTodayUsd + ctx.notionalUsd).toFixed(2)}, over cap $${this.limits.maxDailySpendUsdg}`
46
+ };
47
+ }
48
+ if (ctx.fleetSpentTodayUsd + ctx.notionalUsd > ctx.fleetMaxDailySpendUsdg) {
49
+ return {
50
+ ok: false,
51
+ reason: "fleet_daily_cap",
52
+ detail: `fleet daily spend would reach $${(ctx.fleetSpentTodayUsd + ctx.notionalUsd).toFixed(2)}, over cap $${ctx.fleetMaxDailySpendUsdg}`
53
+ };
54
+ }
55
+ }
56
+ return { ok: true, detail: "within all risk limits" };
57
+ }
58
+ };
59
+ function utcDayStart(now) {
60
+ const d = new Date(now);
61
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
62
+ }
63
+
64
+ // src/framework/agent.ts
65
+ import { formatUnits } from "viem";
66
+ import { buildSwapTx, ensureApproval } from "hoodchain";
67
+ var DUST = 1000n;
68
+ var Agent = class {
69
+ id;
70
+ strategy;
71
+ mode;
72
+ market;
73
+ risk;
74
+ journal;
75
+ kill;
76
+ account;
77
+ opts;
78
+ clock;
79
+ positions = /* @__PURE__ */ new Map();
80
+ lastTradeAt = null;
81
+ realizedUsd = 0;
82
+ spentTodayUsd = 0;
83
+ spentDay = 0;
84
+ ticks = 0;
85
+ trades = 0;
86
+ refusals = 0;
87
+ lastTickAt = null;
88
+ lastError = null;
89
+ timer = null;
90
+ running = false;
91
+ ticking = false;
92
+ constructor(opts) {
93
+ this.opts = opts;
94
+ this.id = opts.id;
95
+ this.strategy = opts.strategy;
96
+ this.mode = opts.mode;
97
+ this.market = opts.market;
98
+ this.risk = new RiskEngine(opts.limits);
99
+ this.journal = opts.journal;
100
+ this.kill = opts.kill;
101
+ this.account = opts.account;
102
+ this.clock = opts.clock ?? Date.now;
103
+ }
104
+ /** Begin the tick loop and wire the strategy's stream subscriptions. */
105
+ async start() {
106
+ if (this.running) return;
107
+ this.running = true;
108
+ const log = (message, meta = {}) => this.journal.recordDecision({ agentId: this.id, ts: this.clock(), kind: "observe", detail: message, meta });
109
+ await this.strategy.start?.({ market: this.market, log });
110
+ this.kill.onKill((reason) => log(`kill switch tripped: ${reason} \u2014 halting new orders`));
111
+ this.scheduleTick();
112
+ }
113
+ /** Stop the loop and the strategy's subscriptions. */
114
+ stop() {
115
+ this.running = false;
116
+ if (this.timer) clearTimeout(this.timer);
117
+ this.timer = null;
118
+ this.strategy.stop?.();
119
+ }
120
+ scheduleTick() {
121
+ if (!this.running) return;
122
+ this.timer = setTimeout(async () => {
123
+ await this.tick();
124
+ this.scheduleTick();
125
+ }, this.opts.tickIntervalMs);
126
+ this.timer.unref?.();
127
+ }
128
+ /** Run one full pipeline pass. Safe to call directly (used by tests). */
129
+ async tick() {
130
+ if (this.ticking) return;
131
+ this.ticking = true;
132
+ const now = this.clock();
133
+ try {
134
+ this.rolloverDay(now);
135
+ await this.markPositions(now);
136
+ if (this.kill.isKilled()) {
137
+ this.recordEquity(now);
138
+ this.ticks++;
139
+ this.lastTickAt = now;
140
+ return;
141
+ }
142
+ const decision = await this.decide(now);
143
+ for (const alert of decision.alerts) {
144
+ this.journal.recordDecision({
145
+ agentId: this.id,
146
+ ts: now,
147
+ kind: "alert",
148
+ detail: `[${alert.level}] ${alert.message}`,
149
+ meta: alert.meta ?? {}
150
+ });
151
+ }
152
+ for (const intent of decision.intents) {
153
+ await this.processIntent(intent, now);
154
+ }
155
+ this.recordEquity(now);
156
+ this.ticks++;
157
+ this.lastTickAt = now;
158
+ this.lastError = null;
159
+ } catch (err) {
160
+ this.lastError = err instanceof Error ? err.message : String(err);
161
+ this.journal.recordDecision({
162
+ agentId: this.id,
163
+ ts: now,
164
+ kind: "observe",
165
+ detail: `tick error: ${this.lastError}`,
166
+ meta: {}
167
+ });
168
+ } finally {
169
+ this.ticking = false;
170
+ }
171
+ }
172
+ async decide(now) {
173
+ const { quoteToken, quoteSymbol, quoteDecimals } = this.quoteInfo();
174
+ return this.strategy.tick({
175
+ market: this.market,
176
+ positions: [...this.positions.values()],
177
+ now,
178
+ quoteToken,
179
+ quoteSymbol,
180
+ quoteDecimals,
181
+ log: (message, meta = {}) => this.journal.recordDecision({ agentId: this.id, ts: now, kind: "observe", detail: message, meta })
182
+ });
183
+ }
184
+ quoteInfo() {
185
+ if (this.strategy.quote === "weth") {
186
+ return { quoteToken: this.market.weth, quoteSymbol: "WETH", quoteDecimals: 18 };
187
+ }
188
+ return { quoteToken: this.market.usdg, quoteSymbol: "USDG", quoteDecimals: this.market.usdgDecimals };
189
+ }
190
+ async processIntent(intent, now) {
191
+ const refuse = (reason, detail, meta = {}) => {
192
+ this.refusals++;
193
+ this.journal.recordDecision({
194
+ agentId: this.id,
195
+ ts: now,
196
+ kind: "refused",
197
+ detail: `${intent.side} ${intent.tokenSymbol}: ${detail}`,
198
+ meta: { reason, intentReason: intent.reason, ...meta }
199
+ });
200
+ };
201
+ const quoteToken = intent.quoteToken;
202
+ const quoteDecimals = intent.quoteSymbol === "USDG" ? this.market.usdgDecimals : 18;
203
+ let sim;
204
+ if (intent.side === "buy") {
205
+ sim = await this.market.quoteBuy(quoteToken, intent.token, intent.amountIn);
206
+ } else {
207
+ sim = await this.market.quoteSell(intent.token, quoteToken, intent.amountIn);
208
+ }
209
+ if (!sim || sim.amountOut <= 0n) {
210
+ refuse("no_route", "no liquid route to simulate the fill");
211
+ return;
212
+ }
213
+ const ethUsd = intent.quoteSymbol === "WETH" ? await this.market.ethUsd(3e4, now) : 1;
214
+ if (ethUsd === null) {
215
+ refuse("no_route", "cannot price ETH to enforce USD caps");
216
+ return;
217
+ }
218
+ let notionalUsd;
219
+ if (intent.side === "buy") {
220
+ notionalUsd = Number(formatUnits(intent.amountIn, quoteDecimals)) * ethUsd;
221
+ } else {
222
+ notionalUsd = Number(formatUnits(sim.amountOut, quoteDecimals)) * ethUsd;
223
+ }
224
+ const existing = this.positions.get(intent.token.toLowerCase());
225
+ if (intent.side === "sell") {
226
+ if (!existing || existing.amount < intent.amountIn - DUST) {
227
+ refuse("insufficient_balance", "position too small to sell requested amount");
228
+ return;
229
+ }
230
+ }
231
+ const positionUsdAfter = intent.side === "buy" ? (existing?.investedUsd ?? 0) + notionalUsd : 0;
232
+ const slippageBps = Math.min(intent.maxSlippageBps ?? this.risk.riskLimits.maxSlippageBps, 1e4);
233
+ const minOut = sim.amountOut * BigInt(1e4 - slippageBps) / 10000n;
234
+ const verdict = this.risk.check({
235
+ side: intent.side,
236
+ notionalUsd,
237
+ positionUsdAfter,
238
+ spentTodayUsd: this.spentTodayUsd,
239
+ fleetSpentTodayUsd: this.opts.fleetSpentTodayUsd(),
240
+ lastTradeAt: this.lastTradeAt,
241
+ slippageBps,
242
+ killed: this.kill.isKilled(),
243
+ now,
244
+ fleetMaxDailySpendUsdg: this.opts.fleetMaxDailySpendUsdg
245
+ });
246
+ if (!verdict.ok) {
247
+ refuse(verdict.reason ?? "refused", verdict.detail, { notionalUsd: round(notionalUsd) });
248
+ return;
249
+ }
250
+ let txHash = null;
251
+ let amountOut = sim.amountOut;
252
+ if (this.mode === "live") {
253
+ const executed = await this.executeLive(intent, sim, slippageBps);
254
+ if (!executed) {
255
+ refuse("no_route", "live execution failed (see logs)");
256
+ return;
257
+ }
258
+ txHash = executed.hash;
259
+ amountOut = executed.amountOutMinimum;
260
+ }
261
+ const trade = {
262
+ agentId: this.id,
263
+ mode: this.mode,
264
+ ts: now,
265
+ side: intent.side,
266
+ token: intent.token,
267
+ tokenSymbol: intent.tokenSymbol,
268
+ quoteToken,
269
+ quoteSymbol: intent.quoteSymbol,
270
+ amountIn: intent.amountIn,
271
+ amountOut,
272
+ txHash,
273
+ reason: intent.reason,
274
+ slippageBps,
275
+ gasEstimate: sim.gasEstimate,
276
+ meta: { ...intent.meta ?? {}, notionalUsd: round(notionalUsd), minOut: minOut.toString() }
277
+ };
278
+ this.journal.recordTrade(trade);
279
+ this.trades++;
280
+ this.lastTradeAt = now;
281
+ this.applyFill(intent, amountOut, notionalUsd, now);
282
+ if (intent.side === "buy") {
283
+ this.spentTodayUsd += notionalUsd;
284
+ this.opts.reportFleetSpend(notionalUsd);
285
+ }
286
+ }
287
+ async executeLive(intent, sim, slippageBps) {
288
+ if (!this.account) return null;
289
+ try {
290
+ const tx = buildSwapTx(this.market.client, sim, { slippageBps });
291
+ await ensureApproval(this.market.client, intent.side === "buy" ? intent.quoteToken : intent.token, intent.amountIn);
292
+ const hash = await this.market.client.wallet.sendTransaction({
293
+ to: tx.to,
294
+ data: tx.data,
295
+ value: tx.value,
296
+ account: this.account,
297
+ chain: this.market.client.chain
298
+ });
299
+ await this.market.client.public.waitForTransactionReceipt({ hash });
300
+ return { hash, amountOutMinimum: tx.amountOutMinimum };
301
+ } catch (err) {
302
+ this.lastError = err instanceof Error ? err.message : String(err);
303
+ return null;
304
+ }
305
+ }
306
+ applyFill(intent, amountOut, notionalUsd, now) {
307
+ const key = intent.token.toLowerCase();
308
+ const existing = this.positions.get(key);
309
+ if (intent.side === "buy") {
310
+ if (existing) {
311
+ existing.amount += amountOut;
312
+ existing.costBasis += intent.amountIn;
313
+ existing.investedUsd += notionalUsd;
314
+ } else {
315
+ this.positions.set(key, {
316
+ token: intent.token,
317
+ tokenSymbol: intent.tokenSymbol,
318
+ amount: amountOut,
319
+ costBasis: intent.amountIn,
320
+ investedUsd: notionalUsd,
321
+ quoteToken: intent.quoteToken,
322
+ quoteSymbol: intent.quoteSymbol,
323
+ openedAt: now,
324
+ markUsd: null,
325
+ meta: intent.meta ?? {}
326
+ });
327
+ }
328
+ return;
329
+ }
330
+ if (!existing) return;
331
+ const sellAmount = intent.amountIn > existing.amount ? existing.amount : intent.amountIn;
332
+ const fraction = existing.amount > 0n ? Number(sellAmount) / Number(existing.amount) : 1;
333
+ const costFractionUsd = existing.investedUsd * fraction;
334
+ const costBasisSold = existing.amount > 0n ? existing.costBasis * sellAmount / existing.amount : existing.costBasis;
335
+ this.realizedUsd += notionalUsd - costFractionUsd;
336
+ existing.amount -= sellAmount;
337
+ existing.costBasis -= costBasisSold;
338
+ existing.investedUsd -= costFractionUsd;
339
+ if (existing.amount <= DUST) this.positions.delete(key);
340
+ }
341
+ /** Mark every open position to its live exit value (a real sell-side quote). */
342
+ async markPositions(now) {
343
+ for (const pos of this.positions.values()) {
344
+ const q = await this.market.quoteSell(pos.token, pos.quoteToken, pos.amount);
345
+ if (!q) {
346
+ pos.markUsd = null;
347
+ continue;
348
+ }
349
+ const quoteDecimals = pos.quoteSymbol === "USDG" ? this.market.usdgDecimals : 18;
350
+ const ethUsd = pos.quoteSymbol === "WETH" ? await this.market.ethUsd(3e4, now) : 1;
351
+ if (ethUsd === null) {
352
+ pos.markUsd = null;
353
+ continue;
354
+ }
355
+ pos.markUsd = Number(formatUnits(q.amountOut, quoteDecimals)) * ethUsd;
356
+ }
357
+ }
358
+ openValueUsd() {
359
+ let sum = 0;
360
+ for (const pos of this.positions.values()) sum += pos.markUsd ?? pos.investedUsd;
361
+ return sum;
362
+ }
363
+ recordEquity(now) {
364
+ const openValueUsd = this.openValueUsd();
365
+ this.journal.recordEquity({
366
+ agentId: this.id,
367
+ ts: now,
368
+ realizedUsd: round(this.realizedUsd),
369
+ openValueUsd: round(openValueUsd),
370
+ equityUsd: round(this.realizedUsd + openValueUsd)
371
+ });
372
+ }
373
+ rolloverDay(now) {
374
+ const day = utcDayStart(now);
375
+ if (day !== this.spentDay) {
376
+ this.spentDay = day;
377
+ this.spentTodayUsd = 0;
378
+ }
379
+ }
380
+ /** Live status snapshot for the dashboard API. */
381
+ status() {
382
+ const openValueUsd = this.openValueUsd();
383
+ return {
384
+ id: this.id,
385
+ strategy: this.strategy.id,
386
+ mode: this.mode,
387
+ running: this.running,
388
+ killed: this.kill.isKilled(),
389
+ limits: this.risk.riskLimits,
390
+ spentTodayUsd: round(this.spentTodayUsd),
391
+ realizedUsd: round(this.realizedUsd),
392
+ openValueUsd: round(openValueUsd),
393
+ equityUsd: round(this.realizedUsd + openValueUsd),
394
+ positions: [...this.positions.values()],
395
+ lastTickAt: this.lastTickAt,
396
+ lastError: this.lastError,
397
+ ticks: this.ticks,
398
+ trades: this.trades,
399
+ refusals: this.refusals
400
+ };
401
+ }
402
+ };
403
+ function round(n) {
404
+ return Math.round(n * 1e6) / 1e6;
405
+ }
406
+
407
+ // src/framework/journal.ts
408
+ import Database from "better-sqlite3";
409
+ import { mkdirSync } from "fs";
410
+ import { dirname } from "path";
411
+ var Journal = class {
412
+ db;
413
+ constructor(dbPath) {
414
+ if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
415
+ this.db = new Database(dbPath);
416
+ this.db.pragma("journal_mode = WAL");
417
+ this.db.pragma("foreign_keys = ON");
418
+ this.migrate();
419
+ }
420
+ migrate() {
421
+ this.db.exec(`
422
+ CREATE TABLE IF NOT EXISTS trades (
423
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
424
+ agent_id TEXT NOT NULL,
425
+ mode TEXT NOT NULL,
426
+ ts INTEGER NOT NULL,
427
+ side TEXT NOT NULL,
428
+ token TEXT NOT NULL,
429
+ token_symbol TEXT NOT NULL,
430
+ quote_token TEXT NOT NULL,
431
+ quote_symbol TEXT NOT NULL,
432
+ amount_in TEXT NOT NULL,
433
+ amount_out TEXT NOT NULL,
434
+ tx_hash TEXT,
435
+ reason TEXT NOT NULL,
436
+ slippage_bps INTEGER NOT NULL,
437
+ gas_estimate TEXT NOT NULL,
438
+ meta TEXT NOT NULL
439
+ );
440
+ CREATE INDEX IF NOT EXISTS idx_trades_agent_ts ON trades(agent_id, ts);
441
+
442
+ CREATE TABLE IF NOT EXISTS decisions (
443
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
444
+ agent_id TEXT NOT NULL,
445
+ ts INTEGER NOT NULL,
446
+ kind TEXT NOT NULL,
447
+ detail TEXT NOT NULL,
448
+ meta TEXT NOT NULL
449
+ );
450
+ CREATE INDEX IF NOT EXISTS idx_decisions_agent_ts ON decisions(agent_id, ts);
451
+
452
+ CREATE TABLE IF NOT EXISTS equity (
453
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
454
+ agent_id TEXT NOT NULL,
455
+ ts INTEGER NOT NULL,
456
+ realized_usd REAL NOT NULL,
457
+ open_value_usd REAL NOT NULL,
458
+ equity_usd REAL NOT NULL
459
+ );
460
+ CREATE INDEX IF NOT EXISTS idx_equity_agent_ts ON equity(agent_id, ts);
461
+ `);
462
+ }
463
+ recordTrade(t) {
464
+ const info = this.db.prepare(
465
+ `INSERT INTO trades
466
+ (agent_id, mode, ts, side, token, token_symbol, quote_token, quote_symbol,
467
+ amount_in, amount_out, tx_hash, reason, slippage_bps, gas_estimate, meta)
468
+ VALUES (@agent_id,@mode,@ts,@side,@token,@token_symbol,@quote_token,@quote_symbol,
469
+ @amount_in,@amount_out,@tx_hash,@reason,@slippage_bps,@gas_estimate,@meta)`
470
+ ).run({
471
+ agent_id: t.agentId,
472
+ mode: t.mode,
473
+ ts: t.ts,
474
+ side: t.side,
475
+ token: t.token,
476
+ token_symbol: t.tokenSymbol,
477
+ quote_token: t.quoteToken,
478
+ quote_symbol: t.quoteSymbol,
479
+ amount_in: t.amountIn.toString(),
480
+ amount_out: t.amountOut.toString(),
481
+ tx_hash: t.txHash,
482
+ reason: t.reason,
483
+ slippage_bps: t.slippageBps,
484
+ gas_estimate: t.gasEstimate.toString(),
485
+ meta: JSON.stringify(t.meta ?? {})
486
+ });
487
+ return Number(info.lastInsertRowid);
488
+ }
489
+ recordDecision(d) {
490
+ const info = this.db.prepare(
491
+ `INSERT INTO decisions (agent_id, ts, kind, detail, meta)
492
+ VALUES (@agent_id,@ts,@kind,@detail,@meta)`
493
+ ).run({
494
+ agent_id: d.agentId,
495
+ ts: d.ts,
496
+ kind: d.kind,
497
+ detail: d.detail,
498
+ meta: JSON.stringify(d.meta ?? {})
499
+ });
500
+ return Number(info.lastInsertRowid);
501
+ }
502
+ recordEquity(e) {
503
+ this.db.prepare(
504
+ `INSERT INTO equity (agent_id, ts, realized_usd, open_value_usd, equity_usd)
505
+ VALUES (?,?,?,?,?)`
506
+ ).run(e.agentId, e.ts, e.realizedUsd, e.openValueUsd, e.equityUsd);
507
+ }
508
+ /** Sum of USDG-denominated spend by an agent since a UTC-day boundary (ms). */
509
+ spentSince(agentId, sinceMs, quoteSymbol = "USDG") {
510
+ const rows = this.db.prepare(
511
+ `SELECT amount_in FROM trades
512
+ WHERE agent_id=? AND ts>=? AND side='buy' AND quote_symbol=?`
513
+ ).all(agentId, sinceMs, quoteSymbol);
514
+ return rows.reduce((sum, r) => sum + Number(BigInt(r.amount_in)) / 1e6, 0);
515
+ }
516
+ recentTrades(agentId, limit = 50) {
517
+ const rows = this.db.prepare(`SELECT * FROM trades WHERE agent_id=? ORDER BY ts DESC LIMIT ?`).all(agentId, limit);
518
+ return rows.map(rowToTrade);
519
+ }
520
+ recentDecisions(agentId, limit = 100) {
521
+ const rows = this.db.prepare(`SELECT * FROM decisions WHERE agent_id=? ORDER BY ts DESC LIMIT ?`).all(agentId, limit);
522
+ return rows.map((r) => ({
523
+ id: r.id,
524
+ agentId: r.agent_id,
525
+ ts: r.ts,
526
+ kind: r.kind,
527
+ detail: r.detail,
528
+ meta: JSON.parse(r.meta || "{}")
529
+ }));
530
+ }
531
+ equityCurve(agentId, limit = 500) {
532
+ const rows = this.db.prepare(
533
+ `SELECT * FROM (
534
+ SELECT * FROM equity WHERE agent_id=? ORDER BY ts DESC LIMIT ?
535
+ ) ORDER BY ts ASC`
536
+ ).all(agentId, limit);
537
+ return rows.map((r) => ({
538
+ agentId: r.agent_id,
539
+ ts: r.ts,
540
+ realizedUsd: r.realized_usd,
541
+ openValueUsd: r.open_value_usd,
542
+ equityUsd: r.equity_usd
543
+ }));
544
+ }
545
+ /** All trades across every agent, newest first — for the fleet-wide feed. */
546
+ allRecentTrades(limit = 100) {
547
+ const rows = this.db.prepare(`SELECT * FROM trades ORDER BY ts DESC LIMIT ?`).all(limit);
548
+ return rows.map(rowToTrade);
549
+ }
550
+ close() {
551
+ this.db.close();
552
+ }
553
+ };
554
+ function rowToTrade(r) {
555
+ return {
556
+ id: r.id,
557
+ agentId: r.agent_id,
558
+ mode: r.mode,
559
+ ts: r.ts,
560
+ side: r.side,
561
+ token: r.token,
562
+ tokenSymbol: r.token_symbol,
563
+ quoteToken: r.quote_token,
564
+ quoteSymbol: r.quote_symbol,
565
+ amountIn: BigInt(r.amount_in),
566
+ amountOut: BigInt(r.amount_out),
567
+ txHash: r.tx_hash ?? null,
568
+ reason: r.reason,
569
+ slippageBps: r.slippage_bps,
570
+ gasEstimate: BigInt(r.gas_estimate),
571
+ meta: JSON.parse(r.meta || "{}")
572
+ };
573
+ }
574
+
575
+ // src/framework/kill.ts
576
+ import { existsSync } from "fs";
577
+ var KillSwitch = class {
578
+ constructor(killFilePath) {
579
+ this.killFilePath = killFilePath;
580
+ }
581
+ killFilePath;
582
+ killed = false;
583
+ reason = null;
584
+ listeners = /* @__PURE__ */ new Set();
585
+ fileTimer = null;
586
+ /** Install SIGINT/SIGTERM handlers and begin polling the kill file. */
587
+ arm() {
588
+ const onSignal = (sig) => () => this.trip(`signal:${sig}`);
589
+ process.once("SIGINT", onSignal("SIGINT"));
590
+ process.once("SIGTERM", onSignal("SIGTERM"));
591
+ const check = () => {
592
+ if (!this.killed && existsSync(this.killFilePath)) this.trip("kill_file");
593
+ };
594
+ check();
595
+ this.fileTimer = setInterval(check, 1e3);
596
+ this.fileTimer.unref?.();
597
+ }
598
+ /** Trip the switch. Idempotent. */
599
+ trip(reason) {
600
+ if (this.killed) return;
601
+ this.killed = true;
602
+ this.reason = reason;
603
+ for (const l of this.listeners) {
604
+ try {
605
+ l(reason);
606
+ } catch {
607
+ }
608
+ }
609
+ }
610
+ isKilled() {
611
+ return this.killed;
612
+ }
613
+ killReason() {
614
+ return this.reason;
615
+ }
616
+ /** Subscribe to the trip event. Returns an unsubscribe fn. */
617
+ onKill(listener) {
618
+ this.listeners.add(listener);
619
+ return () => this.listeners.delete(listener);
620
+ }
621
+ /** Stop the file poller (used in tests/teardown). */
622
+ dispose() {
623
+ if (this.fileTimer) clearInterval(this.fileTimer);
624
+ this.fileTimer = null;
625
+ this.listeners.clear();
626
+ }
627
+ };
628
+
629
+ // src/framework/market.ts
630
+ import {
631
+ createHoodClient,
632
+ getQuote,
633
+ quoteSwap,
634
+ swapAddresses,
635
+ listPricedStockTokens,
636
+ MAINNET_ADDRESSES,
637
+ TESTNET_ADDRESSES,
638
+ USDG_DECIMALS
639
+ } from "hoodchain";
640
+ import { formatUnits as formatUnits2, parseUnits } from "viem";
641
+ var Market = class {
642
+ client;
643
+ usdg;
644
+ weth;
645
+ usdgDecimals = USDG_DECIMALS;
646
+ ethUsdCache = null;
647
+ constructor(config, account) {
648
+ this.client = createHoodClient({
649
+ chain: config.network,
650
+ rpcUrl: config.rpcUrl,
651
+ account,
652
+ acknowledgeStockTokenEligibility: config.stockTokenEligible
653
+ });
654
+ const addrs = config.network === "testnet" ? TESTNET_ADDRESSES : MAINNET_ADDRESSES;
655
+ this.usdg = addrs.usdg;
656
+ this.weth = config.network === "testnet" ? TESTNET_ADDRESSES.weth : MAINNET_ADDRESSES.weth;
657
+ }
658
+ /** Latest block number — a cheap liveness/observe heartbeat. */
659
+ blockNumber() {
660
+ return this.client.public.getBlockNumber();
661
+ }
662
+ /**
663
+ * ETH price in USD from the on-chain USDG/WETH pool (quote 0.01 WETH → USDG).
664
+ * Cached for 30s — it moves slowly relative to a trading tick and every call
665
+ * is a real RPC round trip. Returns `null` if no USDG/WETH pool answers.
666
+ */
667
+ async ethUsd(maxAgeMs = 3e4, now = Date.now()) {
668
+ if (this.ethUsdCache && now - this.ethUsdCache.ts < maxAgeMs) return this.ethUsdCache.value;
669
+ try {
670
+ const probe = parseUnits("0.01", 18);
671
+ const q = await quoteSwap(this.client, { tokenIn: this.weth, tokenOut: this.usdg, amountIn: probe });
672
+ const usd = Number(formatUnits2(q.amountOut, this.usdgDecimals)) / 0.01;
673
+ this.ethUsdCache = { value: usd, ts: now };
674
+ return usd;
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+ /**
680
+ * Spot USD price of one whole `token` from live liquidity. Probes token→USDG
681
+ * directly, then token→WETH→USDG, using a 1-token probe. Returns `null` when
682
+ * neither route has liquidity.
683
+ */
684
+ async spotPrice(token, tokenDecimals = 18, now = Date.now()) {
685
+ const probe = parseUnits("1", tokenDecimals);
686
+ try {
687
+ const q = await quoteSwap(this.client, { tokenIn: token, tokenOut: this.usdg, amountIn: probe });
688
+ return { token, priceUsd: Number(formatUnits2(q.amountOut, this.usdgDecimals)), via: "usdg", ts: now };
689
+ } catch {
690
+ }
691
+ try {
692
+ const q = await quoteSwap(this.client, { tokenIn: token, tokenOut: this.weth, amountIn: probe });
693
+ const eth = await this.ethUsd(3e4, now);
694
+ if (eth === null) return null;
695
+ const priceEth = Number(formatUnits2(q.amountOut, 18));
696
+ return { token, priceUsd: priceEth * eth, via: "weth", ts: now };
697
+ } catch {
698
+ return null;
699
+ }
700
+ }
701
+ /**
702
+ * Quote a buy: how much `token` `amountIn` of `quoteToken` acquires, at live
703
+ * liquidity. This is the simulate step — a QuoterV2 `eth_call`, no state
704
+ * change. Returns `null` when no route fills.
705
+ */
706
+ async quoteBuy(quoteToken, token, amountIn) {
707
+ try {
708
+ return await quoteSwap(this.client, { tokenIn: quoteToken, tokenOut: token, amountIn });
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+ /** Quote a sell: proceeds in `quoteToken` from selling `amount` of `token`. */
714
+ async quoteSell(token, quoteToken, amount) {
715
+ try {
716
+ return await quoteSwap(this.client, { tokenIn: token, tokenOut: quoteToken, amountIn: amount });
717
+ } catch {
718
+ return null;
719
+ }
720
+ }
721
+ /** Chainlink price for a Stock Token (already multiplier-adjusted). */
722
+ async stockChainlinkPrice(symbol) {
723
+ try {
724
+ return await getQuote(this.client, symbol);
725
+ } catch {
726
+ return null;
727
+ }
728
+ }
729
+ /**
730
+ * DEX price of a Stock Token in USD from the USDG pool — the number the
731
+ * premium-watch strategy compares against the Chainlink oracle. Probes with a
732
+ * flat 10 USDG order (small enough to keep impact low across the priced
733
+ * registry) and backs out the implied per-token price from the fill.
734
+ */
735
+ async stockDexPrice(tokenAddress, referenceUsd) {
736
+ if (referenceUsd <= 0) return null;
737
+ const usdgIn = parseUnits("10", this.usdgDecimals);
738
+ const q = await this.quoteBuy(this.usdg, tokenAddress, usdgIn);
739
+ if (!q) return null;
740
+ const tokensOut = Number(formatUnits2(q.amountOut, 18));
741
+ if (tokensOut <= 0) return null;
742
+ return 10 / tokensOut;
743
+ }
744
+ /** Priced Stock Tokens from the registry (those with a Chainlink feed). */
745
+ pricedStockTokens() {
746
+ return listPricedStockTokens();
747
+ }
748
+ /** Router/quoter/weth/usdg set for the active network. */
749
+ addresses() {
750
+ return swapAddresses(this.client);
751
+ }
752
+ };
753
+
754
+ // src/framework/fleet.ts
755
+ import { privateKeyToAccount } from "viem/accounts";
756
+ var Fleet = class {
757
+ config;
758
+ journal;
759
+ kill;
760
+ market;
761
+ account;
762
+ agents = [];
763
+ fleetSpentTodayUsd = 0;
764
+ spentDay = 0;
765
+ startedAt = 0;
766
+ constructor(config) {
767
+ this.config = config;
768
+ this.account = config.privateKey ? privateKeyToAccount(config.privateKey) : null;
769
+ this.journal = new Journal(config.dbPath);
770
+ this.kill = new KillSwitch(config.killFile);
771
+ this.market = new Market(config, this.account ?? void 0);
772
+ }
773
+ /** Build agents from specs. */
774
+ addAgents(specs) {
775
+ for (const spec of specs) {
776
+ const limits = { ...this.config.defaultLimits, ...spec.limits };
777
+ this.agents.push(
778
+ new Agent({
779
+ id: spec.id,
780
+ strategy: spec.strategy,
781
+ market: this.market,
782
+ limits,
783
+ journal: this.journal,
784
+ kill: this.kill,
785
+ mode: this.config.mode,
786
+ account: this.account,
787
+ fleetMaxDailySpendUsdg: this.config.fleetMaxDailySpendUsdg,
788
+ fleetSpentTodayUsd: () => this.currentFleetSpend(),
789
+ reportFleetSpend: (usd) => this.recordFleetSpend(usd),
790
+ tickIntervalMs: spec.tickIntervalMs ?? 5e3
791
+ })
792
+ );
793
+ }
794
+ }
795
+ currentFleetSpend(now = Date.now()) {
796
+ const day = utcDayStart(now);
797
+ if (day !== this.spentDay) {
798
+ this.spentDay = day;
799
+ this.fleetSpentTodayUsd = 0;
800
+ }
801
+ return this.fleetSpentTodayUsd;
802
+ }
803
+ recordFleetSpend(usd) {
804
+ this.currentFleetSpend();
805
+ this.fleetSpentTodayUsd += usd;
806
+ }
807
+ /** Arm the kill switch and start every agent's loop. */
808
+ async start() {
809
+ this.startedAt = Date.now();
810
+ this.kill.arm();
811
+ await Promise.all(this.agents.map((a) => a.start()));
812
+ }
813
+ /** Stop all agents (kill switch stays tripped if it was). */
814
+ stop() {
815
+ for (const a of this.agents) a.stop();
816
+ }
817
+ /** Run exactly one tick per agent, in parallel, without arming the interval scheduler. Used by E2E tests. */
818
+ async tickAllOnce() {
819
+ await Promise.all(this.agents.map((a) => a.tick()));
820
+ }
821
+ /** Trip the kill switch — halts new orders across the fleet. */
822
+ tripKill(reason) {
823
+ this.kill.trip(reason);
824
+ }
825
+ agentStatuses() {
826
+ return this.agents.map((a) => a.status());
827
+ }
828
+ summary() {
829
+ const statuses = this.agentStatuses();
830
+ return {
831
+ network: this.config.network,
832
+ mode: this.config.mode,
833
+ killed: this.kill.isKilled(),
834
+ killReason: this.kill.killReason(),
835
+ fleetSpentTodayUsd: round2(this.currentFleetSpend()),
836
+ fleetMaxDailySpendUsdg: this.config.fleetMaxDailySpendUsdg,
837
+ realizedUsd: round2(statuses.reduce((s, a) => s + a.realizedUsd, 0)),
838
+ openValueUsd: round2(statuses.reduce((s, a) => s + a.openValueUsd, 0)),
839
+ equityUsd: round2(statuses.reduce((s, a) => s + a.equityUsd, 0)),
840
+ agents: this.agents.length,
841
+ startedAt: this.startedAt
842
+ };
843
+ }
844
+ close() {
845
+ this.stop();
846
+ this.kill.dispose();
847
+ this.journal.close();
848
+ }
849
+ };
850
+ function round2(n) {
851
+ return Math.round(n * 1e6) / 1e6;
852
+ }
853
+
854
+ // src/framework/config.ts
855
+ function num(name, fallback) {
856
+ const raw = process.env[name];
857
+ if (raw === void 0 || raw === "") return fallback;
858
+ const n = Number(raw);
859
+ if (!Number.isFinite(n) || n < 0) {
860
+ throw new Error(`Env ${name}="${raw}" is not a non-negative number`);
861
+ }
862
+ return n;
863
+ }
864
+ function bool(name, fallback) {
865
+ const raw = process.env[name];
866
+ if (raw === void 0 || raw === "") return fallback;
867
+ return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes";
868
+ }
869
+ function loadFleetConfig(env = process.env) {
870
+ const network = env.HOOD_NETWORK === "testnet" ? "testnet" : "mainnet";
871
+ const wantLive = bool("HOOD_TRADERS_LIVE", false);
872
+ const privateKey = env.ROBINHOOD_CHAIN_PRIVATE_KEY;
873
+ const hasKey = typeof privateKey === "string" && /^0x[0-9a-fA-F]{64}$/.test(privateKey);
874
+ const mode = wantLive && hasKey ? "live" : "paper";
875
+ return {
876
+ network,
877
+ rpcUrl: env.HOOD_RPC_URL || void 0,
878
+ mode,
879
+ hasWallet: hasKey,
880
+ privateKey: hasKey ? privateKey : void 0,
881
+ stockTokenEligible: bool("HOOD_STOCK_TOKEN_ELIGIBLE", false),
882
+ fleetMaxDailySpendUsdg: num("FLEET_MAX_DAILY_SPEND_USDG", 250),
883
+ dashboardPort: num("DASHBOARD_PORT", 4670),
884
+ killFile: env.KILL_FILE || "./KILL",
885
+ dbPath: env.HOOD_TRADERS_DB || "./data/hood-traders.db",
886
+ defaultLimits: {
887
+ maxPositionUsdg: num("AGENT_MAX_POSITION_USDG", 50),
888
+ maxDailySpendUsdg: num("AGENT_MAX_DAILY_SPEND_USDG", 100),
889
+ maxSlippageBps: num("AGENT_MAX_SLIPPAGE_BPS", 100),
890
+ cooldownSeconds: num("AGENT_COOLDOWN_SECONDS", 60)
891
+ }
892
+ };
893
+ }
894
+
895
+ // src/strategies/launch-sniper.ts
896
+ import { erc20Abi, watchLaunches } from "hoodchain";
897
+ import { formatUnits as formatUnits3, parseEther } from "viem";
898
+ var DEFAULTS = {
899
+ entryWeth: 0.01,
900
+ takeProfitPct: 0.6,
901
+ stopLossPct: 0.35,
902
+ maxHoldSeconds: 30 * 60,
903
+ maxDeployerPct: 0.15,
904
+ maxRoundTripLossPct: 0.35,
905
+ maxLaunchAgeSeconds: 5 * 60
906
+ };
907
+ var LaunchSniper = class {
908
+ id = "launch-sniper";
909
+ title = "Launch Sniper";
910
+ quote = "weth";
911
+ p;
912
+ queue = [];
913
+ seen = /* @__PURE__ */ new Set();
914
+ unwatch = null;
915
+ constructor(params = {}) {
916
+ this.p = { ...DEFAULTS, ...params };
917
+ }
918
+ get meta() {
919
+ return {
920
+ edge: "Harvest opening-minutes volatility of freshly launched memecoins by buying only round-trippable, non-deployer-heavy launches and exiting mechanically on TP/stop/time.",
921
+ failureModes: [
922
+ "Most launches trend to zero \u2014 the stop loss fires frequently; profitability depends on winners covering losers.",
923
+ "Honeypots can enable a sell tax AFTER entry; the entry-time round-trip check cannot see that.",
924
+ "Odyssey bonding-curve tokens have no Uniswap pool pre-graduation and are skipped (this is effectively a NOXA sniper).",
925
+ "Paper fills use the QuoterV2 mid; live, faster bots win the best fills and you eat slippage."
926
+ ],
927
+ params: { ...this.p }
928
+ };
929
+ }
930
+ start(ctx) {
931
+ this.unwatch = watchLaunches(
932
+ ctx.market.client,
933
+ (launch) => {
934
+ const key = launch.token.toLowerCase();
935
+ if (this.seen.has(key)) return;
936
+ this.seen.add(key);
937
+ this.queue.push({ launch, seenAt: Date.now() });
938
+ ctx.log(`new launch queued: ${launch.launchpad} ${launch.token}`, { creator: launch.creator });
939
+ },
940
+ { onError: (e) => ctx.log(`launch watcher error: ${e.message}`) }
941
+ );
942
+ }
943
+ stop() {
944
+ this.unwatch?.();
945
+ this.unwatch = null;
946
+ }
947
+ async tick(ctx) {
948
+ const intents = [];
949
+ const alerts = [];
950
+ for (const pos of ctx.positions) {
951
+ const ageSec = (ctx.now - pos.openedAt) / 1e3;
952
+ const pnlPct = pos.markUsd !== null && pos.investedUsd > 0 ? pos.markUsd / pos.investedUsd - 1 : null;
953
+ let exitReason = null;
954
+ if (pnlPct !== null && pnlPct >= this.p.takeProfitPct) exitReason = `take-profit ${(pnlPct * 100).toFixed(1)}%`;
955
+ else if (pnlPct !== null && pnlPct <= -this.p.stopLossPct) exitReason = `stop-loss ${(pnlPct * 100).toFixed(1)}%`;
956
+ else if (ageSec >= this.p.maxHoldSeconds) exitReason = `time-exit ${Math.round(ageSec)}s held`;
957
+ if (exitReason) {
958
+ intents.push({
959
+ side: "sell",
960
+ token: pos.token,
961
+ tokenSymbol: pos.tokenSymbol,
962
+ amountIn: pos.amount,
963
+ quoteToken: pos.quoteToken,
964
+ quoteSymbol: pos.quoteSymbol,
965
+ reason: exitReason,
966
+ meta: { pnlPct }
967
+ });
968
+ }
969
+ }
970
+ const candidate = this.queue.shift();
971
+ if (candidate) {
972
+ const decisionOrReject = await this.evaluate(ctx, candidate);
973
+ if (decisionOrReject.intent) intents.push(decisionOrReject.intent);
974
+ else if (decisionOrReject.alert) alerts.push(decisionOrReject.alert);
975
+ }
976
+ return { intents, alerts };
977
+ }
978
+ async evaluate(ctx, candidate) {
979
+ const { launch } = candidate;
980
+ const ageSec = (ctx.now - candidate.seenAt) / 1e3;
981
+ if (ageSec > this.p.maxLaunchAgeSeconds) {
982
+ return { alert: { level: "info", message: `skip ${launch.token}: stale (${Math.round(ageSec)}s old)`, meta: {} } };
983
+ }
984
+ if (ctx.positions.some((p) => p.token.toLowerCase() === launch.token.toLowerCase())) return {};
985
+ const amountIn = parseEther(String(this.p.entryWeth));
986
+ const buyQuote = await ctx.market.quoteBuy(ctx.quoteToken, launch.token, amountIn);
987
+ if (!buyQuote || buyQuote.amountOut <= 0n) {
988
+ return { alert: { level: "info", message: `skip ${launch.token}: no liquid Uniswap route`, meta: { launchpad: launch.launchpad } } };
989
+ }
990
+ const sellQuote = await ctx.market.quoteSell(launch.token, ctx.quoteToken, buyQuote.amountOut);
991
+ if (!sellQuote || sellQuote.amountOut <= 0n) {
992
+ return { alert: { level: "warn", message: `skip ${launch.token}: cannot sell back (honeypot?)`, meta: {} } };
993
+ }
994
+ const retention = Number(sellQuote.amountOut) / Number(amountIn);
995
+ if (1 - retention > this.p.maxRoundTripLossPct) {
996
+ return {
997
+ alert: {
998
+ level: "warn",
999
+ message: `skip ${launch.token}: round-trip loss ${((1 - retention) * 100).toFixed(1)}% > ${(this.p.maxRoundTripLossPct * 100).toFixed(0)}%`,
1000
+ meta: { retention }
1001
+ }
1002
+ };
1003
+ }
1004
+ const deployerPct = await this.deployerConcentration(ctx, launch);
1005
+ if (deployerPct !== null && deployerPct > this.p.maxDeployerPct) {
1006
+ return {
1007
+ alert: {
1008
+ level: "warn",
1009
+ message: `skip ${launch.token}: deployer holds ${(deployerPct * 100).toFixed(1)}% > ${(this.p.maxDeployerPct * 100).toFixed(0)}%`,
1010
+ meta: { deployerPct }
1011
+ }
1012
+ };
1013
+ }
1014
+ return {
1015
+ intent: {
1016
+ side: "buy",
1017
+ token: launch.token,
1018
+ tokenSymbol: shortToken(launch.token),
1019
+ amountIn,
1020
+ quoteToken: ctx.quoteToken,
1021
+ quoteSymbol: ctx.quoteSymbol,
1022
+ reason: `sniped ${launch.launchpad} launch \u2014 retention ${(retention * 100).toFixed(1)}%, deployer ${deployerPct === null ? "n/a" : (deployerPct * 100).toFixed(1) + "%"}`,
1023
+ meta: { launchpad: launch.launchpad, deployerPct, retention }
1024
+ }
1025
+ };
1026
+ }
1027
+ async deployerConcentration(ctx, launch) {
1028
+ try {
1029
+ const [supply, bal] = await ctx.market.client.public.multicall({
1030
+ contracts: [
1031
+ { address: launch.token, abi: erc20Abi, functionName: "totalSupply" },
1032
+ { address: launch.token, abi: erc20Abi, functionName: "balanceOf", args: [launch.creator] }
1033
+ ],
1034
+ allowFailure: false
1035
+ });
1036
+ if (supply === 0n) return null;
1037
+ return Number(formatUnits3(bal * 10000n / supply, 4));
1038
+ } catch {
1039
+ return null;
1040
+ }
1041
+ }
1042
+ };
1043
+ function shortToken(addr) {
1044
+ return `${addr.slice(0, 6)}\u2026${addr.slice(-4)}`;
1045
+ }
1046
+
1047
+ // src/strategies/momentum.ts
1048
+ import { getRecentLaunches, parseUsdg } from "hoodchain";
1049
+ var DEFAULTS2 = {
1050
+ entryUsdg: 10,
1051
+ breakoutPct: 0.15,
1052
+ lookbackSamples: 6,
1053
+ trailingStopPct: 0.2,
1054
+ maxHoldSeconds: 60 * 60,
1055
+ maxTracked: 15,
1056
+ discoveryLookbackBlocks: 200000n
1057
+ };
1058
+ var Momentum = class {
1059
+ id = "momentum";
1060
+ title = "Momentum";
1061
+ quote = "usdg";
1062
+ p;
1063
+ history = /* @__PURE__ */ new Map();
1064
+ peakSinceEntry = /* @__PURE__ */ new Map();
1065
+ lastDiscoveryAt = 0;
1066
+ constructor(params = {}) {
1067
+ this.p = { ...DEFAULTS2, ...params };
1068
+ }
1069
+ get meta() {
1070
+ return {
1071
+ edge: "Trend-follow price breakouts on already-graduated, liquid tokens \u2014 surviving the launch phase filters for real demand, and a trailing stop rides the middle of a move.",
1072
+ failureModes: [
1073
+ "Breakouts on thin pools are trivially fakeable by a single wallet; price-only signal has no depth check.",
1074
+ "Trailing stops whipsaw in choppy conditions \u2014 a string of small losses between real trends is expected.",
1075
+ "Discovery only scans a bounded block lookback; breakouts on tokens outside that window are missed until the next pass.",
1076
+ "Momentum has no edge in a flat or declining market \u2014 it is a trend-only strategy by design."
1077
+ ],
1078
+ params: { ...this.p }
1079
+ };
1080
+ }
1081
+ async tick(ctx) {
1082
+ const intents = [];
1083
+ const alerts = [];
1084
+ if (ctx.now - this.lastDiscoveryAt > 5 * 6e4 || this.history.size === 0) {
1085
+ await this.discover(ctx);
1086
+ this.lastDiscoveryAt = ctx.now;
1087
+ }
1088
+ for (const h of this.history.values()) {
1089
+ const spot = await ctx.market.spotPrice(h.token);
1090
+ if (!spot) continue;
1091
+ h.samples.push({ ts: ctx.now, priceUsd: spot.priceUsd });
1092
+ if (h.samples.length > this.p.lookbackSamples + 1) h.samples.shift();
1093
+ }
1094
+ for (const pos of ctx.positions) {
1095
+ const key = pos.token.toLowerCase();
1096
+ const spot = await ctx.market.spotPrice(pos.token);
1097
+ const ageSec = (ctx.now - pos.openedAt) / 1e3;
1098
+ if (spot) {
1099
+ const peak = Math.max(this.peakSinceEntry.get(key) ?? spot.priceUsd, spot.priceUsd);
1100
+ this.peakSinceEntry.set(key, peak);
1101
+ const drawdown = peak > 0 ? 1 - spot.priceUsd / peak : 0;
1102
+ if (drawdown >= this.p.trailingStopPct) {
1103
+ intents.push(this.exitIntent(pos, ctx, `trailing stop: ${(drawdown * 100).toFixed(1)}% off peak $${peak.toFixed(6)}`));
1104
+ this.peakSinceEntry.delete(key);
1105
+ continue;
1106
+ }
1107
+ }
1108
+ if (ageSec >= this.p.maxHoldSeconds) {
1109
+ intents.push(this.exitIntent(pos, ctx, `time-exit ${Math.round(ageSec)}s held`));
1110
+ this.peakSinceEntry.delete(key);
1111
+ }
1112
+ }
1113
+ for (const h of this.history.values()) {
1114
+ if (h.samples.length < this.p.lookbackSamples) continue;
1115
+ if (ctx.positions.some((p) => p.token.toLowerCase() === h.token.toLowerCase())) continue;
1116
+ const first = h.samples[h.samples.length - this.p.lookbackSamples];
1117
+ const last = h.samples[h.samples.length - 1];
1118
+ if (!first || !last || first.priceUsd <= 0) continue;
1119
+ const gain = last.priceUsd / first.priceUsd - 1;
1120
+ if (gain >= this.p.breakoutPct) {
1121
+ const amountIn = parseUsdg(String(this.p.entryUsdg));
1122
+ intents.push({
1123
+ side: "buy",
1124
+ token: h.token,
1125
+ tokenSymbol: h.symbol,
1126
+ amountIn,
1127
+ quoteToken: ctx.quoteToken,
1128
+ quoteSymbol: ctx.quoteSymbol,
1129
+ reason: `breakout +${(gain * 100).toFixed(1)}% over ${this.p.lookbackSamples} samples ($${first.priceUsd.toFixed(6)}\u2192$${last.priceUsd.toFixed(6)})`,
1130
+ meta: { gain, entryPriceUsd: last.priceUsd }
1131
+ });
1132
+ this.peakSinceEntry.set(h.token.toLowerCase(), last.priceUsd);
1133
+ } else {
1134
+ alerts.push({
1135
+ level: "info",
1136
+ message: `${h.symbol} watch: ${(gain * 100).toFixed(1)}% vs ${(this.p.breakoutPct * 100).toFixed(0)}% breakout threshold`
1137
+ });
1138
+ }
1139
+ }
1140
+ return { intents, alerts };
1141
+ }
1142
+ exitIntent(pos, ctx, reason) {
1143
+ return {
1144
+ side: "sell",
1145
+ token: pos.token,
1146
+ tokenSymbol: pos.tokenSymbol,
1147
+ amountIn: pos.amount,
1148
+ quoteToken: pos.quoteToken,
1149
+ quoteSymbol: pos.quoteSymbol,
1150
+ reason
1151
+ };
1152
+ }
1153
+ async discover(ctx) {
1154
+ let launches;
1155
+ try {
1156
+ launches = await getRecentLaunches(ctx.market.client, { lookbackBlocks: this.p.discoveryLookbackBlocks });
1157
+ } catch (err) {
1158
+ ctx.log(`momentum discovery failed: ${err instanceof Error ? err.message : String(err)}`);
1159
+ return;
1160
+ }
1161
+ const graduated = launches.filter((l) => l.launchpad === "noxa" || l.pool !== null);
1162
+ const candidates = graduated.slice(-this.p.maxTracked);
1163
+ for (const l of candidates) {
1164
+ const key = l.token.toLowerCase();
1165
+ if (this.history.has(key)) continue;
1166
+ const spot = await ctx.market.spotPrice(l.token);
1167
+ if (!spot) continue;
1168
+ this.history.set(key, { token: l.token, symbol: shortToken2(l.token), samples: [{ ts: ctx.now, priceUsd: spot.priceUsd }] });
1169
+ }
1170
+ const keep = new Set(candidates.map((l) => l.token.toLowerCase()));
1171
+ for (const key of [...this.history.keys()]) {
1172
+ if (!keep.has(key) && !ctx.positions.some((p) => p.token.toLowerCase() === key)) this.history.delete(key);
1173
+ }
1174
+ }
1175
+ };
1176
+ function shortToken2(addr) {
1177
+ return `${addr.slice(0, 6)}\u2026${addr.slice(-4)}`;
1178
+ }
1179
+
1180
+ // src/strategies/premium-watch.ts
1181
+ import { getStockToken, parseUsdg as parseUsdg2 } from "hoodchain";
1182
+ var DEFAULT_SYMBOLS = ["AAPL", "TSLA", "NVDA", "AMZN", "MSFT"];
1183
+ var DEFAULTS3 = {
1184
+ symbols: DEFAULT_SYMBOLS,
1185
+ alertThresholdBps: 50,
1186
+ tradeThresholdBps: 150,
1187
+ tradeSizeUsdg: 20,
1188
+ exitThresholdBps: 20,
1189
+ maxHoldSeconds: 2 * 60 * 60,
1190
+ enableTrading: false
1191
+ };
1192
+ var PremiumWatch = class {
1193
+ id = "premium-watch";
1194
+ title = "Premium Watch";
1195
+ quote = "usdg";
1196
+ p;
1197
+ constructor(params = {}) {
1198
+ this.p = { ...DEFAULTS3, ...params };
1199
+ }
1200
+ get meta() {
1201
+ return {
1202
+ edge: "Track Chainlink-vs-DEX spread on Stock Tokens and, only in eligible+opted-in configuration, buy discounts expecting reversion as the thin pool re-equilibrates. Default mode is alerts-only.",
1203
+ failureModes: [
1204
+ "A premium/discount can be correct \u2014 DEX price may reflect information the \u226424h Chainlink heartbeat has not caught up to; fading it then loses on purpose.",
1205
+ "Stock Token pools are thin; the probe price does not reflect real trade-size slippage, and the trade itself moves the spread being arbitraged.",
1206
+ "No shorting primitive exists, so only discounts (DEX cheap) are ever tradeable \u2014 premiums are alert-only by construction, not by choice.",
1207
+ "Chainlink staleness (weekend gap, feed outage) can masquerade as a discount when the oracle, not the pool, is stale.",
1208
+ "Trading requires an explicit eligibility affirmation (Stock Tokens cannot be sold to US/CA/UK/CH persons) \u2014 default is alerts-only until the operator opts in."
1209
+ ],
1210
+ params: { ...this.p, enableTrading: this.p.enableTrading }
1211
+ };
1212
+ }
1213
+ async tick(ctx) {
1214
+ const intents = [];
1215
+ const alerts = [];
1216
+ const tradingLive = this.p.enableTrading && ctx.market.client.acknowledgeStockTokenEligibility;
1217
+ if (this.p.enableTrading && !ctx.market.client.acknowledgeStockTokenEligibility) {
1218
+ alerts.push({
1219
+ level: "warn",
1220
+ message: "premium-watch: enableTrading=true but the client has not acknowledged Stock Token eligibility \u2014 staying alerts-only"
1221
+ });
1222
+ }
1223
+ for (const pos of ctx.positions) {
1224
+ const symbol = pos.tokenSymbol;
1225
+ const spread = await this.readSpread(ctx, symbol);
1226
+ const ageSec = (ctx.now - pos.openedAt) / 1e3;
1227
+ let exitReason = null;
1228
+ if (spread && Math.abs(spread.bps) <= this.p.exitThresholdBps) {
1229
+ exitReason = `converged: spread ${spread.bps.toFixed(0)}bps within exit band`;
1230
+ } else if (ageSec >= this.p.maxHoldSeconds) {
1231
+ exitReason = `time-exit ${Math.round(ageSec)}s held`;
1232
+ }
1233
+ if (exitReason) {
1234
+ intents.push({
1235
+ side: "sell",
1236
+ token: pos.token,
1237
+ tokenSymbol: symbol,
1238
+ amountIn: pos.amount,
1239
+ quoteToken: pos.quoteToken,
1240
+ quoteSymbol: pos.quoteSymbol,
1241
+ reason: exitReason
1242
+ });
1243
+ }
1244
+ }
1245
+ for (const symbol of this.p.symbols) {
1246
+ const spread = await this.readSpread(ctx, symbol);
1247
+ if (!spread) continue;
1248
+ const direction = spread.bps > 0 ? "premium (DEX rich)" : "discount (DEX cheap)";
1249
+ if (Math.abs(spread.bps) >= this.p.alertThresholdBps) {
1250
+ alerts.push({
1251
+ level: "warn",
1252
+ message: `${symbol} ${direction}: oracle $${spread.oracleUsd.toFixed(2)} vs DEX $${spread.dexUsd.toFixed(2)} (${spread.bps.toFixed(0)}bps)`,
1253
+ meta: { symbol, oracleUsd: spread.oracleUsd, dexUsd: spread.dexUsd, bps: spread.bps }
1254
+ });
1255
+ }
1256
+ const alreadyHeld = ctx.positions.some((p) => p.tokenSymbol === symbol);
1257
+ const isDiscount = spread.bps <= -this.p.tradeThresholdBps;
1258
+ if (tradingLive && isDiscount && !alreadyHeld) {
1259
+ const token = getStockToken(symbol);
1260
+ intents.push({
1261
+ side: "buy",
1262
+ token: token.address,
1263
+ tokenSymbol: symbol,
1264
+ amountIn: parseUsdg2(String(this.p.tradeSizeUsdg)),
1265
+ quoteToken: ctx.quoteToken,
1266
+ quoteSymbol: ctx.quoteSymbol,
1267
+ reason: `convergence entry: ${symbol} discount ${spread.bps.toFixed(0)}bps (oracle $${spread.oracleUsd.toFixed(2)} vs DEX $${spread.dexUsd.toFixed(2)})`,
1268
+ meta: { symbol, oracleUsd: spread.oracleUsd, dexUsd: spread.dexUsd, bps: spread.bps }
1269
+ });
1270
+ } else if (isDiscount && !tradingLive) {
1271
+ alerts.push({
1272
+ level: "info",
1273
+ message: `${symbol} discount ${spread.bps.toFixed(0)}bps clears trade threshold but trading is disabled/ineligible \u2014 alert only`
1274
+ });
1275
+ }
1276
+ }
1277
+ return { intents, alerts };
1278
+ }
1279
+ async readSpread(ctx, symbol) {
1280
+ const oracle = await ctx.market.stockChainlinkPrice(symbol);
1281
+ if (!oracle) return null;
1282
+ const token = getStockToken(symbol);
1283
+ const dexUsd = await ctx.market.stockDexPrice(token.address, oracle.priceUsd);
1284
+ if (dexUsd === null) return null;
1285
+ const bps = (dexUsd - oracle.priceUsd) / oracle.priceUsd * 1e4;
1286
+ return { oracleUsd: oracle.priceUsd, dexUsd, bps };
1287
+ }
1288
+ };
1289
+
1290
+ // src/server/dashboard.ts
1291
+ import { createServer } from "http";
1292
+ import { readFile } from "fs/promises";
1293
+ import { extname, join } from "path";
1294
+ var MIME = {
1295
+ ".html": "text/html; charset=utf-8",
1296
+ ".js": "text/javascript; charset=utf-8",
1297
+ ".css": "text/css; charset=utf-8",
1298
+ ".json": "application/json; charset=utf-8",
1299
+ ".svg": "image/svg+xml"
1300
+ };
1301
+ function json(res, status, body) {
1302
+ const payload = JSON.stringify(body, (_key, value) => typeof value === "bigint" ? value.toString() : value);
1303
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
1304
+ res.end(payload);
1305
+ }
1306
+ async function serveStatic(staticRoot, req, res) {
1307
+ const url = new URL(req.url ?? "/", "http://localhost");
1308
+ let rel = url.pathname === "/" ? "/index.html" : url.pathname;
1309
+ if (rel.includes("..")) return false;
1310
+ const path = join(staticRoot, rel);
1311
+ try {
1312
+ const body = await readFile(path);
1313
+ res.writeHead(200, { "content-type": MIME[extname(path)] ?? "application/octet-stream" });
1314
+ res.end(body);
1315
+ return true;
1316
+ } catch {
1317
+ return false;
1318
+ }
1319
+ }
1320
+ function createDashboardServer(fleet, staticRoot) {
1321
+ return createServer(async (req, res) => {
1322
+ const url = new URL(req.url ?? "/", "http://localhost");
1323
+ if (url.pathname === "/api/summary" && req.method === "GET") {
1324
+ return json(res, 200, fleet.summary());
1325
+ }
1326
+ if (url.pathname === "/api/agents" && req.method === "GET") {
1327
+ return json(res, 200, fleet.agentStatuses());
1328
+ }
1329
+ if (url.pathname.startsWith("/api/agents/") && url.pathname.endsWith("/trades") && req.method === "GET") {
1330
+ const agentId = decodeURIComponent(url.pathname.split("/")[3] ?? "");
1331
+ return json(res, 200, fleet.journal.recentTrades(agentId, 100));
1332
+ }
1333
+ if (url.pathname.startsWith("/api/agents/") && url.pathname.endsWith("/journal") && req.method === "GET") {
1334
+ const agentId = decodeURIComponent(url.pathname.split("/")[3] ?? "");
1335
+ return json(res, 200, fleet.journal.recentDecisions(agentId, 200));
1336
+ }
1337
+ if (url.pathname.startsWith("/api/agents/") && url.pathname.endsWith("/equity") && req.method === "GET") {
1338
+ const agentId = decodeURIComponent(url.pathname.split("/")[3] ?? "");
1339
+ return json(res, 200, fleet.journal.equityCurve(agentId, 500));
1340
+ }
1341
+ if (url.pathname === "/api/trades" && req.method === "GET") {
1342
+ return json(res, 200, fleet.journal.allRecentTrades(100));
1343
+ }
1344
+ if (url.pathname === "/api/kill" && req.method === "POST") {
1345
+ fleet.tripKill("dashboard");
1346
+ return json(res, 200, { killed: true, reason: "dashboard" });
1347
+ }
1348
+ if (url.pathname === "/api/health" && req.method === "GET") {
1349
+ return json(res, 200, { ok: true });
1350
+ }
1351
+ if (await serveStatic(staticRoot, req, res)) return;
1352
+ res.writeHead(404, { "content-type": "text/plain" });
1353
+ res.end("not found");
1354
+ });
1355
+ }
1356
+
1357
+ export {
1358
+ RiskEngine,
1359
+ utcDayStart,
1360
+ Agent,
1361
+ Journal,
1362
+ KillSwitch,
1363
+ Market,
1364
+ Fleet,
1365
+ loadFleetConfig,
1366
+ LaunchSniper,
1367
+ Momentum,
1368
+ PremiumWatch,
1369
+ createDashboardServer
1370
+ };
1371
+ //# sourceMappingURL=chunk-NXL2F7EP.js.map