paper_sell
Sell an open paper trade on Solana memecoins. Retrieves real-time exit price and calculates profit or loss.
Instructions
Sell an open paper position. Fetches real-time exit price and calculates P&L.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| mint | Yes | Solana token mint address to sell | |
| reason | No | Reason for selling (e.g. "taking profit", "cutting loss") |
Implementation Reference
- src/paper-engine.js:111-155 (handler)The sell() method on PaperEngine class executes the sell logic: validates the mint, fetches current price, applies slippage, calculates P&L, updates balance and trade log, deletes the position, persists state, and returns the result.
async sell(mint, reason = 'manual') { this.#validateMint(mint); const position = this.#positions.get(mint); if (!position) { return { success: false, reason: `No open position for ${mint.slice(0, 8)}` }; } const currentPrice = await this.#fetchPrice(mint); if (!currentPrice) { return { success: false, reason: 'Could not fetch exit price' }; } const slippage = 1 - (Math.random() * 0.004 + 0.001); const fillPrice = currentPrice * slippage; const solReceived = position.tokensHeld * fillPrice; this.#balance += solReceived; const txid = `PAPER_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const pnlSol = solReceived - position.entrySol; const pnlPct = ((fillPrice - position.entryPrice) / position.entryPrice) * 100; const holdTimeMin = Math.round((Date.now() - position.entryTime) / 60_000); const entry = { type: 'sell', reason, txid, mint, symbol: position.symbol, solReceived: this.#round(solReceived), fillPrice, pnlSol: this.#round(pnlSol), pnlPct: Math.round(pnlPct * 100) / 100, holdTimeMin, timestamp: new Date().toISOString(), balanceAfter: this.#round(this.#balance), }; this.#tradeLog.push(entry); this.#positions.delete(mint); this.#persist(); return { success: true, txid, action: 'sell', symbol: position.symbol, mint, solReceived: entry.solReceived, pnlSol: entry.pnlSol, pnlPct: entry.pnlPct, holdTimeMin, reason, balanceAfter: this.#round(this.#balance), }; - src/tools.js:158-190 (registration)The 'paper_sell' tool is registered using server.tool() with a zod schema for mint (required) and reason (optional), and metadata (destructiveHint: true). The handler delegates to either a remote trader API or the local PaperEngine.sell() method.
server.tool( 'paper_sell', 'Sell an open paper position. Fetches real-time exit price and calculates P&L.', { mint: z.string().regex(MINT_REGEX) .describe('Solana token mint address to sell'), reason: z.string().max(50).optional() .describe('Reason for selling (e.g. "taking profit", "cutting loss")'), }, { title: 'Paper Sell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, async ({ mint, reason }) => { try { let result; if (useRemoteTrader) { result = await traderFetch('/sell', { method: 'POST', body: JSON.stringify({ mint, reason: reason || 'manual' }), }); } else { result = await paper.sell(mint, reason || 'manual'); } return text(JSON.stringify(result, null, 2)); } catch (err) { return error(err.message); } } ); - src/tools.js:161-166 (schema)Input schema for paper_sell: 'mint' (Solana token mint address, regex validated) and optional 'reason' (max 50 chars, describes why selling).
{ mint: z.string().regex(MINT_REGEX) .describe('Solana token mint address to sell'), reason: z.string().max(50).optional() .describe('Reason for selling (e.g. "taking profit", "cutting loss")'), }, - src/tools.js:174-189 (handler)The tool's handler function: calls traderFetch('/sell') if useRemoteTrader, otherwise calls paper.sell(mint, reason). Returns JSON result or error message.
async ({ mint, reason }) => { try { let result; if (useRemoteTrader) { result = await traderFetch('/sell', { method: 'POST', body: JSON.stringify({ mint, reason: reason || 'manual' }), }); } else { result = await paper.sell(mint, reason || 'manual'); } return text(JSON.stringify(result, null, 2)); } catch (err) { return error(err.message); } } - src/paper-engine.js:1-329 (helper)PaperEngine class provides the complete paper trading engine including state management, price fetching, persistence, and risk checks. Its sell() method is the core logic invoked by the paper_sell tool.
import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; const LAMPORTS_PER_SOL = 1_000_000_000; const PRICE_TIMEOUT_MS = 8_000; const SOL_MINT = 'So11111111111111111111111111111111111111112'; const BASE58_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; export class PaperEngine { #config; #positions = new Map(); #tradeLog = []; #priceCache = new Map(); #balance; #startingBalance; #startTime = Date.now(); #persistPath; constructor(config = {}) { this.#config = { startingBalance: config.startingBalance ?? 10, maxPositionSol: config.maxPositionSol ?? 0.5, maxExposureSol: config.maxExposureSol ?? 5.0, stopLossPct: config.stopLossPct ?? 15, takeProfitPct: config.takeProfitPct ?? 50, minLiquidityUsd: config.minLiquidityUsd ?? 10_000, }; this.#startingBalance = this.#config.startingBalance; this.#balance = this.#startingBalance; const dataDir = config.persistPath || join(homedir(), '.piquesignal'); try { mkdirSync(dataDir, { recursive: true }); } catch {} this.#persistPath = join(dataDir, 'paper-state.json'); this.#restore(); } // ── Public API ────────────────────────────────────────── async buy(mint, amountSol, signalData = {}) { this.#validateMint(mint); const liquidity = signalData.liquidity_usd || 0; const confidence = Math.max(0, Math.min(1, (signalData.score || 50) / 100)); const riskCheck = this.#checkRisk(mint, liquidity); if (!riskCheck.passed) { return { success: false, rejected: true, reason: riskCheck.reason }; } let size = Math.min(amountSol || this.#config.maxPositionSol, this.#config.maxPositionSol); const remaining = this.#config.maxExposureSol - this.#totalExposure(); size = Math.min(size, remaining); if (confidence < 1.0) size *= Math.max(confidence, 0.25); size = Math.round(size * 1000) / 1000; if (size > this.#balance) { return { success: false, rejected: true, reason: `Insufficient balance: ${this.#balance} SOL < ${size} SOL` }; } const price = await this.#fetchPrice(mint); if (!price) { return { success: false, rejected: true, reason: 'Could not fetch token price' }; } const slippage = 1 + (Math.random() * 0.004 + 0.001); const fillPrice = price * slippage; const tokensReceived = (size * LAMPORTS_PER_SOL) / (fillPrice * LAMPORTS_PER_SOL); this.#balance -= size; const txid = `PAPER_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const position = { mint, symbol: signalData.symbol || mint.slice(0, 8), entrySol: size, entryPrice: fillPrice, tokensHeld: tokensReceived, entryTime: Date.now(), txid, stopLoss: fillPrice * (1 - this.#config.stopLossPct / 100), takeProfit: fillPrice * (1 + this.#config.takeProfitPct / 100), highWaterMark: fillPrice, }; this.#positions.set(mint, position); this.#priceCache.set(mint, fillPrice); const entry = { type: 'buy', txid, mint, symbol: position.symbol, amountSol: size, fillPrice, tokensReceived, slippagePct: ((slippage - 1) * 100).toFixed(3), timestamp: new Date().toISOString(), balanceAfter: this.#round(this.#balance), }; this.#tradeLog.push(entry); this.#persist(); return { success: true, txid, action: 'buy', symbol: position.symbol, mint, amountSol: size, fillPrice, tokensReceived, stopLoss: position.stopLoss, takeProfit: position.takeProfit, balanceRemaining: this.#round(this.#balance), }; } async sell(mint, reason = 'manual') { this.#validateMint(mint); const position = this.#positions.get(mint); if (!position) { return { success: false, reason: `No open position for ${mint.slice(0, 8)}` }; } const currentPrice = await this.#fetchPrice(mint); if (!currentPrice) { return { success: false, reason: 'Could not fetch exit price' }; } const slippage = 1 - (Math.random() * 0.004 + 0.001); const fillPrice = currentPrice * slippage; const solReceived = position.tokensHeld * fillPrice; this.#balance += solReceived; const txid = `PAPER_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const pnlSol = solReceived - position.entrySol; const pnlPct = ((fillPrice - position.entryPrice) / position.entryPrice) * 100; const holdTimeMin = Math.round((Date.now() - position.entryTime) / 60_000); const entry = { type: 'sell', reason, txid, mint, symbol: position.symbol, solReceived: this.#round(solReceived), fillPrice, pnlSol: this.#round(pnlSol), pnlPct: Math.round(pnlPct * 100) / 100, holdTimeMin, timestamp: new Date().toISOString(), balanceAfter: this.#round(this.#balance), }; this.#tradeLog.push(entry); this.#positions.delete(mint); this.#persist(); return { success: true, txid, action: 'sell', symbol: position.symbol, mint, solReceived: entry.solReceived, pnlSol: entry.pnlSol, pnlPct: entry.pnlPct, holdTimeMin, reason, balanceAfter: this.#round(this.#balance), }; } async getPositions() { const results = []; const triggered = []; for (const [mint, pos] of this.#positions) { const currentPrice = await this.#fetchPrice(mint) || this.#priceCache.get(mint) || pos.entryPrice; this.#priceCache.set(mint, currentPrice); if (currentPrice > pos.highWaterMark) pos.highWaterMark = currentPrice; const pnlPct = ((currentPrice - pos.entryPrice) / pos.entryPrice) * 100; if (currentPrice <= pos.stopLoss) { triggered.push({ mint, reason: 'stop_loss', pnlPct }); } else if (currentPrice >= pos.takeProfit) { triggered.push({ mint, reason: 'take_profit', pnlPct }); } results.push({ mint, symbol: pos.symbol, entrySol: pos.entrySol, entryPrice: pos.entryPrice, currentPrice, unrealizedPnlPct: Math.round(pnlPct * 100) / 100, holdTimeMin: Math.round((Date.now() - pos.entryTime) / 60_000), stopLoss: pos.stopLoss, takeProfit: pos.takeProfit, highWaterMark: pos.highWaterMark, }); } // Auto-execute SL/TP const autoSells = []; for (const t of triggered) { const result = await this.sell(t.mint, t.reason); if (result.success) autoSells.push(result); } return { positions: results, autoSells }; } getPortfolio() { const sells = this.#tradeLog.filter(t => t.type === 'sell'); const wins = sells.filter(t => t.pnlSol > 0); const losses = sells.filter(t => t.pnlSol <= 0); const totalRealizedPnl = sells.reduce((s, t) => s + (t.pnlSol || 0), 0); return { balance: this.#round(this.#balance), startingBalance: this.#startingBalance, totalPnlPct: Math.round(((this.#balance - this.#startingBalance) / this.#startingBalance) * 10000) / 100, realizedPnlSol: this.#round(totalRealizedPnl), openPositions: this.#positions.size, totalExposureSol: this.#round(this.#totalExposure()), completedTrades: sells.length, winRate: sells.length ? Math.round((wins.length / sells.length) * 1000) / 10 + '%' : 'N/A', wins: wins.length, losses: losses.length, avgWinPct: wins.length ? Math.round(wins.reduce((s, t) => s + t.pnlPct, 0) / wins.length * 100) / 100 : 0, avgLossPct: losses.length ? Math.round(losses.reduce((s, t) => s + t.pnlPct, 0) / losses.length * 100) / 100 : 0, uptimeMin: Math.round((Date.now() - this.#startTime) / 60_000), recentTrades: this.#tradeLog.slice(-10), riskConfig: { ...this.#config }, }; } async getPrice(mint) { this.#validateMint(mint); const price = await this.#fetchPrice(mint); if (!price) return { mint, price: null, source: null }; return { mint, price, cached: false }; } // ── Risk Checks ───────────────────────────────────────── #checkRisk(mint, liquidityUsd) { if (this.#positions.has(mint)) { return { passed: false, reason: `Already holding ${mint.slice(0, 8)}` }; } if (this.#totalExposure() >= this.#config.maxExposureSol) { return { passed: false, reason: `Max exposure reached (${this.#config.maxExposureSol} SOL)` }; } if (liquidityUsd > 0 && liquidityUsd < this.#config.minLiquidityUsd) { return { passed: false, reason: `Liquidity $${liquidityUsd} below min $${this.#config.minLiquidityUsd}` }; } return { passed: true }; } // ── Price Fetching ────────────────────────────────────── async #fetchPrice(mint) { return await this.#priceFromJupiter(mint) || await this.#priceFromDexScreener(mint); } async #priceFromJupiter(mint) { try { const res = await fetch( `https://api.jup.ag/price/v2?ids=${encodeURIComponent(mint)}&vsToken=${SOL_MINT}`, { signal: AbortSignal.timeout(PRICE_TIMEOUT_MS) } ); if (!res.ok) return null; const data = await res.json(); const p = data.data?.[mint]?.price; return p ? parseFloat(p) : null; } catch { return null; } } async #priceFromDexScreener(mint) { try { const res = await fetch( `https://api.dexscreener.com/latest/dex/tokens/${encodeURIComponent(mint)}`, { signal: AbortSignal.timeout(PRICE_TIMEOUT_MS) } ); if (!res.ok) return null; const data = await res.json(); const pair = data.pairs?.find(p => p.chainId === 'solana' && p.quoteToken?.symbol === 'SOL'); if (pair?.priceNative) return parseFloat(pair.priceNative); const any = data.pairs?.[0]; if (any?.priceNative) return parseFloat(any.priceNative); return null; } catch { return null; } } // ── Validation ────────────────────────────────────────── #validateMint(mint) { if (!mint || typeof mint !== 'string' || !BASE58_REGEX.test(mint)) { throw new Error('Invalid Solana mint address'); } } // ── Persistence ───────────────────────────────────────── #persist() { try { const state = { balance: this.#balance, startingBalance: this.#startingBalance, positions: Object.fromEntries(this.#positions), tradeLog: this.#tradeLog.slice(-200), savedAt: new Date().toISOString(), }; writeFileSync(this.#persistPath, JSON.stringify(state, null, 2), 'utf-8'); } catch {} } #restore() { try { const raw = readFileSync(this.#persistPath, 'utf-8'); const state = JSON.parse(raw); if (state.balance !== undefined) this.#balance = state.balance; if (state.startingBalance !== undefined) this.#startingBalance = state.startingBalance; if (state.positions) { for (const [k, v] of Object.entries(state.positions)) { this.#positions.set(k, v); } } if (state.tradeLog) this.#tradeLog = state.tradeLog; } catch {} } // ── Helpers ───────────────────────────────────────────── #totalExposure() { let total = 0; for (const pos of this.#positions.values()) total += pos.entrySol; return total; } #round(n) { return Math.round(n * 1000) / 1000; } }