@potswap_io/mcp 0.0.0-stage → 0.2.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -3
  3. package/dist/api.d.ts +41 -0
  4. package/dist/api.js +91 -0
  5. package/dist/config.d.ts +22 -0
  6. package/dist/config.js +24 -0
  7. package/dist/format.d.ts +3 -0
  8. package/dist/format.js +7 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +29 -0
  11. package/dist/limits.d.ts +13 -0
  12. package/dist/limits.js +23 -0
  13. package/dist/redact.d.ts +5 -0
  14. package/dist/redact.js +23 -0
  15. package/dist/runtime.d.ts +15 -0
  16. package/dist/runtime.js +39 -0
  17. package/dist/server.d.ts +20 -0
  18. package/dist/server.js +201 -0
  19. package/dist/shared/errors.d.ts +5 -0
  20. package/dist/shared/errors.js +24 -0
  21. package/dist/shared/fee.d.ts +24 -0
  22. package/dist/shared/fee.js +42 -0
  23. package/dist/shared/lib-types.d.ts +42 -0
  24. package/dist/shared/lib-types.js +1 -0
  25. package/dist/shared/risk.d.ts +15 -0
  26. package/dist/shared/risk.js +35 -0
  27. package/dist/shared/types.d.ts +50 -0
  28. package/dist/shared/types.js +1 -0
  29. package/dist/strategies/engine.d.ts +40 -0
  30. package/dist/strategies/engine.js +100 -0
  31. package/dist/strategies/lock.d.ts +7 -0
  32. package/dist/strategies/lock.js +30 -0
  33. package/dist/strategies/scheduler.d.ts +38 -0
  34. package/dist/strategies/scheduler.js +219 -0
  35. package/dist/strategies/store.d.ts +26 -0
  36. package/dist/strategies/store.js +53 -0
  37. package/dist/strategies/tools.d.ts +10 -0
  38. package/dist/strategies/tools.js +143 -0
  39. package/dist/strategies/types.d.ts +68 -0
  40. package/dist/strategies/types.js +2 -0
  41. package/dist/trade.d.ts +86 -0
  42. package/dist/trade.js +83 -0
  43. package/dist/wallet.d.ts +10 -0
  44. package/dist/wallet.js +48 -0
  45. package/package.json +58 -6
@@ -0,0 +1,38 @@
1
+ import type { TradeContext } from '../trade.js';
2
+ import type { StrategyStore } from './store.js';
3
+ import type { Run } from './types.js';
4
+ export interface SchedulerOptions {
5
+ store: StrategyStore;
6
+ trade: TradeContext;
7
+ maxDailySol: number;
8
+ now?: () => number;
9
+ tickMs?: number;
10
+ onRun?: (run: Run) => void;
11
+ }
12
+ export declare class Scheduler {
13
+ private store;
14
+ private trade;
15
+ private maxDailySol;
16
+ private now;
17
+ private tickMs;
18
+ private onRun?;
19
+ private timer;
20
+ private running;
21
+ /** Set while a leg executes so a stop() can wait for it (P11.11). */
22
+ inFlight: Promise<void> | null;
23
+ active: boolean;
24
+ constructor(o: SchedulerOptions);
25
+ start(): void;
26
+ stop(): Promise<void>;
27
+ /** One pass over all strategies. Safe to call concurrently; overlapping calls are skipped. */
28
+ tick(): Promise<void>;
29
+ private runTick;
30
+ private runDca;
31
+ private runRebalance;
32
+ private checkTpSl;
33
+ private execute;
34
+ private record;
35
+ private bumpFailure;
36
+ private advance;
37
+ private complete;
38
+ }
@@ -0,0 +1,219 @@
1
+ import { executeSwap } from '../trade.js';
2
+ import { log } from '../redact.js';
3
+ import { dcaIsComplete, dcaLegFor, isDue, MAX_CONSECUTIVE_FAILURES, nextRunAfter, newId, rebalancePlan, solSpentToday, tpslDecision } from './engine.js';
4
+ export class Scheduler {
5
+ store;
6
+ trade;
7
+ maxDailySol;
8
+ now;
9
+ tickMs;
10
+ onRun;
11
+ timer = null;
12
+ running = false;
13
+ /** Set while a leg executes so a stop() can wait for it (P11.11). */
14
+ inFlight = null;
15
+ active = false;
16
+ constructor(o) {
17
+ this.store = o.store;
18
+ this.trade = o.trade;
19
+ this.maxDailySol = o.maxDailySol;
20
+ this.now = o.now ?? Date.now;
21
+ this.tickMs = o.tickMs ?? 30_000;
22
+ this.onRun = o.onRun;
23
+ }
24
+ start() {
25
+ if (this.timer)
26
+ return;
27
+ this.active = true;
28
+ this.timer = setInterval(() => { void this.tick(); }, this.tickMs);
29
+ void this.tick();
30
+ }
31
+ async stop() {
32
+ if (this.timer)
33
+ clearInterval(this.timer);
34
+ this.timer = null;
35
+ this.active = false;
36
+ if (this.inFlight)
37
+ await this.inFlight;
38
+ }
39
+ /** One pass over all strategies. Safe to call concurrently; overlapping calls are skipped. */
40
+ async tick() {
41
+ if (this.running)
42
+ return;
43
+ this.running = true;
44
+ const p = this.runTick().catch((e) => log('scheduler tick error', e instanceof Error ? e.message : String(e)));
45
+ this.inFlight = p;
46
+ try {
47
+ await p;
48
+ }
49
+ finally {
50
+ this.running = false;
51
+ this.inFlight = null;
52
+ }
53
+ }
54
+ async runTick() {
55
+ const now = this.now();
56
+ const state = this.store.load();
57
+ for (const s of state.strategies) {
58
+ if (s.status !== 'active')
59
+ continue;
60
+ if (s.kind === 'tpsl')
61
+ await this.checkTpSl(s);
62
+ else if (isDue(s, now))
63
+ await (s.kind === 'dca' ? this.runDca(s) : this.runRebalance(s));
64
+ }
65
+ }
66
+ // ---- DCA (P11.1–P11.3)
67
+ async runDca(s) {
68
+ const leg = dcaLegFor(s);
69
+ const out = await this.execute(s, leg);
70
+ this.store.update((st) => {
71
+ const cur = st.strategies.find((x) => x.id === s.id);
72
+ if (!cur || cur.status !== 'active')
73
+ return;
74
+ const now = this.now();
75
+ if (out.kind === 'ok' || out.kind === 'dry-run')
76
+ cur.runsDone++;
77
+ cur.nextRunAt = nextRunAfter(cur, now);
78
+ if (dcaIsComplete(cur)) {
79
+ cur.status = 'completed';
80
+ cur.nextRunAt = null;
81
+ }
82
+ });
83
+ }
84
+ // ---- Rebalance (P11.4)
85
+ async runRebalance(s) {
86
+ const wallet = this.trade.signer?.publicKey;
87
+ let balances = {}, prices = {};
88
+ try {
89
+ balances = wallet ? await this.trade.balances(wallet) : {};
90
+ prices = await this.trade.api.prices(Object.keys(s.targets));
91
+ }
92
+ catch (e) {
93
+ this.record(s, 'check', 'failed', `Could not read balances or prices: ${e instanceof Error ? e.message : String(e)}. No funds were debited.`);
94
+ this.bumpFailure(s.id);
95
+ this.advance(s.id);
96
+ return;
97
+ }
98
+ const plan = rebalancePlan(s, balances, prices);
99
+ for (const w of plan.warnings)
100
+ this.record(s, 'check', 'skipped', w);
101
+ if (plan.legs.length === 0) {
102
+ this.record(s, 'check', 'no-op', `Within ${s.driftPct}% of target: ${Object.entries(plan.weights).map(([m, w]) => `${short(m)} ${w.toFixed(1)}%`).join(', ')}`);
103
+ }
104
+ else {
105
+ for (const leg of plan.legs)
106
+ await this.execute(s, leg);
107
+ }
108
+ this.advance(s.id);
109
+ }
110
+ // ---- TP/SL (P11.5)
111
+ async checkTpSl(s) {
112
+ let price;
113
+ try {
114
+ price = (await this.trade.api.prices([s.mint]))[s.mint];
115
+ }
116
+ catch {
117
+ price = undefined;
118
+ }
119
+ if (price === undefined) {
120
+ this.record(s, 'check', 'skipped', 'price unavailable');
121
+ return;
122
+ }
123
+ const { trigger, highWater } = tpslDecision(s, price);
124
+ this.store.update((st) => { const cur = st.strategies.find((x) => x.id === s.id); if (cur)
125
+ cur.highWater = highWater; });
126
+ if (!trigger)
127
+ return;
128
+ const wallet = this.trade.signer?.publicKey;
129
+ const held = wallet ? (await this.trade.balances(wallet).catch(() => ({})))[s.mint] ?? 0 : 0;
130
+ const amount = Math.floor(held * (s.sellPct / 100) * 1e9) / 1e9;
131
+ if (amount <= 0) {
132
+ this.record(s, `sell ${s.sellPct}% (${trigger.trigger})`, 'completed', 'Nothing to sell; completing.');
133
+ this.complete(s.id);
134
+ return;
135
+ }
136
+ const out = await this.execute(s, { inputMint: s.mint, outputMint: s.exitMint, amount, reason: `${trigger.trigger} at ${price} (threshold ${trigger.threshold.toPrecision(6)})` });
137
+ if (out.kind === 'ok' || out.kind === 'dry-run')
138
+ this.complete(s.id);
139
+ }
140
+ // ---- shared leg execution with caps, run log, failure counting (P11.6–P11.8, P11.13)
141
+ async execute(s, leg) {
142
+ const now = this.now();
143
+ const spent = solSpentToday(this.store.load().runs, now);
144
+ const remainingSol = Math.max(0, this.maxDailySol - spent);
145
+ const out = await executeSwap(this.trade, {
146
+ inputMint: leg.inputMint, outputMint: leg.outputMint, amount: leg.amount, slippageBps: s.maxSlippageBps, confirm: true,
147
+ extraSolCap: { remainingSol, label: `Daily cap reached (${this.maxDailySol} SOL). Next legs resume at 00:00 UTC.` },
148
+ });
149
+ const action = `${leg.amount} ${short(leg.inputMint)} → ${short(leg.outputMint)} (${leg.reason})`;
150
+ switch (out.kind) {
151
+ case 'ok': {
152
+ const r = out.result;
153
+ this.record(s, action, 'ok', undefined, { signature: r.signature, inAmount: r.inAmount, inputSymbol: r.inputSymbol, outAmount: r.outAmount, outputSymbol: r.outputSymbol, feeUsd: r.feeUsd, pointsAdded: r.pointsAdded, solEquivalent: r.solEquivalent });
154
+ this.store.update((st) => {
155
+ const cur = st.strategies.find((x) => x.id === s.id);
156
+ if (!cur)
157
+ return;
158
+ cur.consecutiveFailures = 0;
159
+ cur.totals.runs++;
160
+ cur.totals.feeUsd += r.feeUsd ?? 0;
161
+ cur.totals.pointsAdded += r.pointsAdded ?? 0;
162
+ cur.totals.volumeUsd += out.quote.inputUsd ?? 0;
163
+ });
164
+ break;
165
+ }
166
+ case 'dry-run':
167
+ this.record(s, action, 'dry-run', `Would swap for ${out.quote.outAmount} ${out.quote.outputSymbol} via ${out.quote.router}`);
168
+ break;
169
+ case 'refused':
170
+ this.record(s, action, 'skipped', out.message);
171
+ break;
172
+ case 'quote-only':
173
+ this.record(s, action, 'skipped', 'internal: confirm missing');
174
+ break;
175
+ case 'no-wallet':
176
+ this.record(s, action, 'failed', out.message);
177
+ this.bumpFailure(s.id);
178
+ break;
179
+ case 'failed':
180
+ this.record(s, action, 'failed', out.message, { signature: out.signature });
181
+ this.bumpFailure(s.id);
182
+ break;
183
+ }
184
+ return out;
185
+ }
186
+ record(s, action, outcome, message, extra = {}) {
187
+ const run = { id: newId('run', this.now()), strategyId: s.id, at: new Date(this.now()).toISOString(), action, outcome, message, ...extra };
188
+ this.store.update((st) => { st.runs.push(run); });
189
+ this.onRun?.(run);
190
+ log(`[${s.kind} ${s.id}] ${outcome}: ${action}${message ? ` — ${message}` : ''}`);
191
+ }
192
+ bumpFailure(id) {
193
+ this.store.update((st) => {
194
+ const cur = st.strategies.find((x) => x.id === id);
195
+ if (!cur)
196
+ return;
197
+ cur.consecutiveFailures++;
198
+ if (cur.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
199
+ cur.status = 'paused';
200
+ cur.pausedReason = `${MAX_CONSECUTIVE_FAILURES} consecutive failures; resume after checking the wallet and route.`;
201
+ cur.nextRunAt = null;
202
+ }
203
+ });
204
+ }
205
+ advance(id) {
206
+ this.store.update((st) => {
207
+ const cur = st.strategies.find((x) => x.id === id);
208
+ if (cur && cur.status === 'active' && cur.kind !== 'tpsl')
209
+ cur.nextRunAt = nextRunAfter(cur, this.now());
210
+ });
211
+ }
212
+ complete(id) {
213
+ this.store.update((st) => { const cur = st.strategies.find((x) => x.id === id); if (cur) {
214
+ cur.status = 'completed';
215
+ cur.nextRunAt = null;
216
+ } });
217
+ }
218
+ }
219
+ const short = (m) => (m === 'So11111111111111111111111111111111111111112' ? 'SOL' : m.slice(0, 4) + '…' + m.slice(-4));
@@ -0,0 +1,26 @@
1
+ import { type Run, type Strategy, type StrategyState } from './types.js';
2
+ export interface StrategyStore {
3
+ load(): StrategyState;
4
+ save(state: StrategyState): void;
5
+ /** Convenience: read-modify-write. */
6
+ update<T>(fn: (state: StrategyState) => T): T;
7
+ readonly location: string;
8
+ }
9
+ export declare const MAX_RUNS_KEPT = 2000;
10
+ export declare class MemoryStore implements StrategyStore {
11
+ private state;
12
+ location: string;
13
+ load(): StrategyState;
14
+ save(state: StrategyState): void;
15
+ update<T>(fn: (state: StrategyState) => T): T;
16
+ }
17
+ export declare class FileStore implements StrategyStore {
18
+ location: string;
19
+ constructor(dir?: string);
20
+ load(): StrategyState;
21
+ save(state: StrategyState): void;
22
+ update<T>(fn: (state: StrategyState) => T): T;
23
+ }
24
+ export declare function defaultStateDir(env?: NodeJS.ProcessEnv): string;
25
+ export declare const findStrategy: (state: StrategyState, id: string) => Strategy | undefined;
26
+ export declare const runsOf: (state: StrategyState, id: string, limit?: number) => Run[];
@@ -0,0 +1,53 @@
1
+ // PRD P11 — persistence: a JSON file with atomic writes (tmp + rename), plus an in-memory store for tests.
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { redact } from '../redact.js';
6
+ import { EMPTY_STATE } from './types.js';
7
+ export const MAX_RUNS_KEPT = 2000;
8
+ export class MemoryStore {
9
+ state = structuredClone(EMPTY_STATE);
10
+ location = 'memory';
11
+ load() { return structuredClone(this.state); }
12
+ save(state) { this.state = structuredClone(trim(state)); }
13
+ update(fn) { const s = this.load(); const r = fn(s); this.save(s); return r; }
14
+ }
15
+ export class FileStore {
16
+ location;
17
+ constructor(dir = defaultStateDir()) {
18
+ mkdirSync(dir, { recursive: true });
19
+ this.location = join(dir, 'strategies.json');
20
+ }
21
+ load() {
22
+ if (!existsSync(this.location))
23
+ return structuredClone(EMPTY_STATE);
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(this.location, 'utf8'));
26
+ if (parsed.version !== 1 || !Array.isArray(parsed.strategies))
27
+ return structuredClone(EMPTY_STATE);
28
+ parsed.runs ??= [];
29
+ return parsed;
30
+ }
31
+ catch {
32
+ return structuredClone(EMPTY_STATE);
33
+ }
34
+ }
35
+ save(state) {
36
+ // Redaction is belt and braces: nothing here should hold a key, but the file is on disk forever.
37
+ const body = redact(JSON.stringify(trim(state), null, 2));
38
+ const tmp = `${this.location}.${process.pid}.tmp`;
39
+ writeFileSync(tmp, body);
40
+ renameSync(tmp, this.location);
41
+ }
42
+ update(fn) { const s = this.load(); const r = fn(s); this.save(s); return r; }
43
+ }
44
+ export function defaultStateDir(env = process.env) {
45
+ return env.POTSWAP_STATE_DIR || join(homedir(), '.potswap');
46
+ }
47
+ function trim(state) {
48
+ if (state.runs.length > MAX_RUNS_KEPT)
49
+ state.runs = state.runs.slice(-MAX_RUNS_KEPT);
50
+ return state;
51
+ }
52
+ export const findStrategy = (state, id) => state.strategies.find((s) => s.id === id);
53
+ export const runsOf = (state, id, limit = 50) => state.runs.filter((r) => r.strategyId === id).slice(-limit).reverse();
@@ -0,0 +1,10 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { TradeContext } from '../trade.js';
3
+ import { type StrategyStore } from './store.js';
4
+ import type { Scheduler } from './scheduler.js';
5
+ export interface StrategyDeps {
6
+ trade: TradeContext;
7
+ store?: StrategyStore;
8
+ scheduler?: Scheduler;
9
+ }
10
+ export declare function registerStrategyTools(server: McpServer, deps: StrategyDeps): void;
@@ -0,0 +1,143 @@
1
+ import { z } from 'zod';
2
+ import { NO_WALLET } from '../config.js';
3
+ import { redact, redactValue } from '../redact.js';
4
+ import { newId, SOL_MINT, nextRunAfter } from './engine.js';
5
+ import { findStrategy, runsOf } from './store.js';
6
+ const MINT = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/);
7
+ const MAX_STRATEGIES = Number(process.env.POTSWAP_MAX_STRATEGIES) || 10;
8
+ const ok = (data, summary) => ({ content: [{ type: 'text', text: redact(summary ?? JSON.stringify(data, null, 2)) }], structuredContent: redactValue(data) });
9
+ const fail = (message, extra = {}) => ({ content: [{ type: 'text', text: redact(message) }], structuredContent: redactValue({ error: message, ...extra }), isError: true });
10
+ export function registerStrategyTools(server, deps) {
11
+ const { trade } = deps;
12
+ if (!deps.store)
13
+ return; // strategies disabled (no state dir)
14
+ const store = deps.store;
15
+ const scheduler = deps.scheduler;
16
+ const now = () => Date.now();
17
+ const base = (kind, maxSlippageBps) => ({
18
+ id: newId(kind, now()), createdAt: new Date(now()).toISOString(), status: 'active', nextRunAt: new Date(now()).toISOString(),
19
+ consecutiveFailures: 0, totals: { runs: 0, volumeUsd: 0, feeUsd: 0, pointsAdded: 0 }, maxSlippageBps,
20
+ });
21
+ const guard = (confirm) => {
22
+ if (!trade.signer)
23
+ return fail(NO_WALLET);
24
+ if (!confirm)
25
+ return fail('Set confirm=true to create a strategy. Nothing was created and no funds were debited.');
26
+ if (store.load().strategies.filter((s) => s.status === 'active').length >= MAX_STRATEGIES)
27
+ return fail(`Limit of ${MAX_STRATEGIES} active strategies reached. Pause or delete one first.`);
28
+ return null;
29
+ };
30
+ const created = async (s, summary) => {
31
+ store.update((st) => { st.strategies.push(s); });
32
+ // First leg right away (P11.1) when a scheduler is attached.
33
+ if (scheduler)
34
+ await scheduler.tick();
35
+ const fresh = findStrategy(store.load(), s.id) ?? s;
36
+ return ok({ strategy: fresh, schedulerActive: !!scheduler?.active, runs: runsOf(store.load(), s.id, 3) }, `${summary}\nid=${s.id}${scheduler?.active ? '' : '\nNote: no scheduler is running in this process; run `npx @potswap_io/mcp --daemon` to execute strategies 24/7.'}`);
37
+ };
38
+ server.registerTool('dca_create', {
39
+ title: 'Create a DCA strategy',
40
+ description: 'Buy a fixed amount of a token on a schedule (first run immediately). Every leg pays the Potswap fee and earns POT points. Runs while this host is open, or 24/7 with `npx @potswap_io/mcp --daemon`.',
41
+ inputSchema: {
42
+ inputMint: MINT, outputMint: MINT,
43
+ amountPerRun: z.number().positive().describe('UI units of the input token per run (1 = 1 SOL)'),
44
+ intervalMinutes: z.number().int().min(5).max(60 * 24 * 30),
45
+ totalRuns: z.number().int().min(1).max(1000).optional(),
46
+ maxSlippageBps: z.number().int().min(1).max(5000).optional(),
47
+ confirm: z.boolean().default(false),
48
+ },
49
+ }, async (a) => {
50
+ const g = guard(a.confirm);
51
+ if (g)
52
+ return g;
53
+ if (a.inputMint === a.outputMint)
54
+ return fail('inputMint and outputMint must differ. Nothing was created.');
55
+ const s = { ...base('dca', a.maxSlippageBps), kind: 'dca', inputMint: a.inputMint, outputMint: a.outputMint, amountPerRun: a.amountPerRun, intervalMinutes: a.intervalMinutes, totalRuns: a.totalRuns, runsDone: 0 };
56
+ return created(s, `DCA created: ${a.amountPerRun} per run every ${a.intervalMinutes} min${a.totalRuns ? ` × ${a.totalRuns}` : ''}.`);
57
+ });
58
+ server.registerTool('rebalance_create', {
59
+ title: 'Create a rebalance strategy',
60
+ description: 'Keep the wallet at target percentages (2–6 assets incl. SOL). Checks on an interval; trades only when an asset drifts more than driftPct, routing through SOL.',
61
+ inputSchema: {
62
+ targets: z.record(MINT, z.number().min(0).max(100)).describe('mint → percent; must sum to 100 and include SOL'),
63
+ driftPct: z.number().min(1).max(50).default(5),
64
+ checkIntervalMinutes: z.number().int().min(15).max(60 * 24 * 7).default(60),
65
+ minTradeUsd: z.number().min(1).default(5),
66
+ maxSlippageBps: z.number().int().min(1).max(5000).optional(),
67
+ confirm: z.boolean().default(false),
68
+ },
69
+ }, async (a) => {
70
+ const g = guard(a.confirm);
71
+ if (g)
72
+ return g;
73
+ const mints = Object.keys(a.targets);
74
+ const sum = Object.values(a.targets).reduce((x, y) => x + y, 0);
75
+ if (mints.length < 2 || mints.length > 6)
76
+ return fail('targets must list 2–6 assets. Nothing was created.');
77
+ if (Math.abs(sum - 100) > 0.5)
78
+ return fail(`targets must sum to 100 (got ${sum}). Nothing was created.`);
79
+ if (!(SOL_MINT in a.targets))
80
+ return fail('targets must include SOL (So11111111111111111111111111111111111111112). Nothing was created.');
81
+ const s = { ...base('rebalance', a.maxSlippageBps), kind: 'rebalance', targets: a.targets, driftPct: a.driftPct, checkIntervalMinutes: a.checkIntervalMinutes, minTradeUsd: a.minTradeUsd };
82
+ return created(s, `Rebalance created: ${mints.map((m) => `${m === SOL_MINT ? 'SOL' : m.slice(0, 4)} ${a.targets[m]}%`).join(' / ')}, drift ${a.driftPct}%, every ${a.checkIntervalMinutes} min.`);
83
+ });
84
+ server.registerTool('tpsl_create', {
85
+ title: 'Create a take-profit / stop-loss',
86
+ description: 'Sell a percentage of a position when its price reaches a take-profit, stop-loss or trailing-stop level (entry = price now). Checks every 30 s while a scheduler runs.',
87
+ inputSchema: {
88
+ mint: MINT, sellPct: z.number().min(1).max(100).default(100),
89
+ takeProfitPct: z.number().positive().optional(), stopLossPct: z.number().positive().max(99).optional(), trailingPct: z.number().positive().max(99).optional(),
90
+ exitMint: MINT.default(SOL_MINT), maxSlippageBps: z.number().int().min(1).max(5000).optional(),
91
+ confirm: z.boolean().default(false),
92
+ },
93
+ }, async (a) => {
94
+ const g = guard(a.confirm);
95
+ if (g)
96
+ return g;
97
+ if (a.takeProfitPct === undefined && a.stopLossPct === undefined && a.trailingPct === undefined)
98
+ return fail('Set at least one of takeProfitPct, stopLossPct, trailingPct. Nothing was created.');
99
+ if (a.mint === a.exitMint)
100
+ return fail('mint and exitMint must differ. Nothing was created.');
101
+ let entry;
102
+ try {
103
+ entry = (await trade.api.prices([a.mint]))[a.mint];
104
+ }
105
+ catch {
106
+ entry = undefined;
107
+ }
108
+ if (!entry)
109
+ return fail('No price available for this token right now; try again in a moment. Nothing was created.');
110
+ const s = { ...base('tpsl', a.maxSlippageBps), kind: 'tpsl', mint: a.mint, sellPct: a.sellPct, exitMint: a.exitMint, entryPrice: entry, highWater: entry, takeProfitPct: a.takeProfitPct, stopLossPct: a.stopLossPct, trailingPct: a.trailingPct, nextRunAt: null };
111
+ return created(s, `TP/SL created at entry ${entry}: ${[a.takeProfitPct && `TP +${a.takeProfitPct}%`, a.stopLossPct && `SL −${a.stopLossPct}%`, a.trailingPct && `trailing ${a.trailingPct}%`].filter(Boolean).join(', ')}, sells ${a.sellPct}%.`);
112
+ });
113
+ server.registerTool('strategy_list', { title: 'List strategies', description: 'All strategies with status, next run, totals and last result.', inputSchema: {} }, async () => {
114
+ const st = store.load();
115
+ const rows = st.strategies.map((s) => ({ ...s, lastRun: runsOf(st, s.id, 1)[0] ?? null }));
116
+ return ok({ schedulerActive: !!scheduler?.active, stateFile: store.location, strategies: rows }, rows.length ? rows.map((s) => `${s.id} ${s.kind} ${s.status}${s.pausedReason ? ` (${s.pausedReason})` : ''} next=${s.nextRunAt ?? '—'} runs=${s.totals.runs} fees=$${s.totals.feeUsd.toFixed(2)}`).join('\n') : 'No strategies yet.');
117
+ });
118
+ const byId = { id: z.string().min(3) };
119
+ server.registerTool('strategy_pause', { title: 'Pause a strategy', description: 'Stops future legs; keeps history.', inputSchema: byId }, async ({ id }) => mutate(id, (s) => { s.status = 'paused'; s.pausedReason = 'paused by user'; s.nextRunAt = null; }));
120
+ server.registerTool('strategy_resume', { title: 'Resume a strategy', description: 'Re-activates a paused strategy; the next run is scheduled from now.', inputSchema: byId }, async ({ id }) => mutate(id, (s) => { if (s.status === 'completed')
121
+ throw new Error('Completed strategies cannot be resumed; create a new one.'); s.status = 'active'; s.pausedReason = undefined; s.consecutiveFailures = 0; s.nextRunAt = s.kind === 'tpsl' ? null : nextRunAfter(s, now()); }));
122
+ server.registerTool('strategy_delete', { title: 'Delete a strategy', description: 'Removes the strategy; its run history is kept.', inputSchema: byId }, async ({ id }) => {
123
+ const existed = store.update((st) => { const n = st.strategies.length; st.strategies = st.strategies.filter((s) => s.id !== id); return n !== st.strategies.length; });
124
+ return existed ? ok({ deleted: id }, `Deleted ${id}.`) : fail(`No strategy ${id}.`);
125
+ });
126
+ server.registerTool('strategy_runs', { title: 'Strategy run log', description: 'Recent legs with amounts, signatures, fees, points, or the skip/fail reason.', inputSchema: { id: z.string().min(3), limit: z.number().int().min(1).max(200).default(20) } }, async ({ id, limit }) => {
127
+ const st = store.load();
128
+ if (!findStrategy(st, id))
129
+ return fail(`No strategy ${id}.`);
130
+ const runs = runsOf(st, id, limit);
131
+ return ok({ id, runs }, runs.length ? runs.map((r) => `${r.at} ${r.outcome}: ${r.action}${r.signature ? ` sig=${r.signature}` : ''}${r.message ? ` — ${r.message}` : ''}`).join('\n') : 'No runs yet.');
132
+ });
133
+ function mutate(id, fn) {
134
+ try {
135
+ const s = store.update((st) => { const cur = findStrategy(st, id); if (!cur)
136
+ return null; fn(cur); return cur; });
137
+ return s ? ok({ strategy: s }, `${s.id} is now ${s.status}.`) : fail(`No strategy ${id}.`);
138
+ }
139
+ catch (e) {
140
+ return fail(e instanceof Error ? e.message : String(e));
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,68 @@
1
+ export type StrategyStatus = 'active' | 'paused' | 'completed';
2
+ interface Base {
3
+ id: string;
4
+ createdAt: string;
5
+ status: StrategyStatus;
6
+ pausedReason?: string;
7
+ nextRunAt: string | null;
8
+ consecutiveFailures: number;
9
+ totals: {
10
+ runs: number;
11
+ volumeUsd: number;
12
+ feeUsd: number;
13
+ pointsAdded: number;
14
+ };
15
+ maxSlippageBps?: number;
16
+ }
17
+ export interface DcaStrategy extends Base {
18
+ kind: 'dca';
19
+ inputMint: string;
20
+ outputMint: string;
21
+ amountPerRun: number;
22
+ intervalMinutes: number;
23
+ totalRuns?: number;
24
+ runsDone: number;
25
+ }
26
+ export interface RebalanceStrategy extends Base {
27
+ kind: 'rebalance';
28
+ targets: Record<string, number>;
29
+ driftPct: number;
30
+ checkIntervalMinutes: number;
31
+ minTradeUsd: number;
32
+ }
33
+ export interface TpSlStrategy extends Base {
34
+ kind: 'tpsl';
35
+ mint: string;
36
+ sellPct: number;
37
+ exitMint: string;
38
+ entryPrice: number;
39
+ highWater: number;
40
+ takeProfitPct?: number;
41
+ stopLossPct?: number;
42
+ trailingPct?: number;
43
+ }
44
+ export type Strategy = DcaStrategy | RebalanceStrategy | TpSlStrategy;
45
+ export type RunOutcome = 'ok' | 'skipped' | 'failed' | 'dry-run' | 'no-op' | 'completed';
46
+ export interface Run {
47
+ id: string;
48
+ strategyId: string;
49
+ at: string;
50
+ action: string;
51
+ outcome: RunOutcome;
52
+ message?: string;
53
+ signature?: string;
54
+ inAmount?: number;
55
+ inputSymbol?: string;
56
+ outAmount?: number;
57
+ outputSymbol?: string;
58
+ feeUsd?: number;
59
+ pointsAdded?: number;
60
+ solEquivalent?: number;
61
+ }
62
+ export interface StrategyState {
63
+ version: 1;
64
+ strategies: Strategy[];
65
+ runs: Run[];
66
+ }
67
+ export declare const EMPTY_STATE: StrategyState;
68
+ export {};
@@ -0,0 +1,2 @@
1
+ // PRD P11 — strategy definitions persisted in ~/.potswap/strategies.json.
2
+ export const EMPTY_STATE = { version: 1, strategies: [], runs: [] };
@@ -0,0 +1,86 @@
1
+ import type { PotswapApi } from './api.js';
2
+ import { type Config } from './config.js';
3
+ import type { Signer } from './wallet.js';
4
+ export type BalancesFn = (wallet: string) => Promise<Record<string, number>>;
5
+ export interface TradeContext {
6
+ api: PotswapApi;
7
+ config: Config;
8
+ signer: Signer | null;
9
+ balances: BalancesFn;
10
+ }
11
+ export interface SwapArgs {
12
+ inputMint: string;
13
+ outputMint: string;
14
+ amount: number;
15
+ slippageBps?: number;
16
+ confirm?: boolean;
17
+ /** Extra SOL-equivalent already spent in the current window (strategies' daily cap). */
18
+ extraSolCap?: {
19
+ remainingSol: number;
20
+ label: string;
21
+ };
22
+ }
23
+ export interface Quote {
24
+ inputMint: string;
25
+ inputSymbol: string;
26
+ outputMint: string;
27
+ outputSymbol: string;
28
+ inAmount: number;
29
+ inAmountRaw: string;
30
+ outAmount: number;
31
+ price: number;
32
+ priceImpactPct?: number;
33
+ slippageBps?: number;
34
+ feeBps: number;
35
+ feeTier: string | null;
36
+ feeLabel: string | null;
37
+ scheduledFeeBps: number;
38
+ inputUsd?: number;
39
+ router: string;
40
+ requestId: string;
41
+ expiresAt: string | null;
42
+ }
43
+ export interface SwapOk {
44
+ signature: string;
45
+ explorerUrl: string;
46
+ inAmount: number;
47
+ inputSymbol: string;
48
+ outAmount: number;
49
+ outputSymbol: string;
50
+ router: string;
51
+ feeBps: number;
52
+ feeUsd?: number;
53
+ pointsAdded?: number;
54
+ solEquivalent?: number;
55
+ }
56
+ export type SwapOutcome = {
57
+ kind: 'no-wallet';
58
+ message: string;
59
+ } | {
60
+ kind: 'refused';
61
+ message: string;
62
+ quote: Quote;
63
+ } | {
64
+ kind: 'quote-only';
65
+ quote: Quote;
66
+ } | {
67
+ kind: 'dry-run';
68
+ quote: Quote;
69
+ } | {
70
+ kind: 'failed';
71
+ message: string;
72
+ quote?: Quote;
73
+ signature?: string;
74
+ explorerUrl?: string;
75
+ } | {
76
+ kind: 'ok';
77
+ result: SwapOk;
78
+ quote: Quote;
79
+ };
80
+ export declare function buildQuote(api: PotswapApi, a: {
81
+ inputMint: string;
82
+ outputMint: string;
83
+ amount: number;
84
+ slippageBps?: number;
85
+ }): Promise<Quote>;
86
+ export declare function executeSwap(ctx: TradeContext, a: SwapArgs): Promise<SwapOutcome>;