mobile_trader

Free Signal-Feed Guide

Run your own executor

End-to-end guide: spin up a Frankfurt VPS, create a fresh Solana wallet, install a Node.js executor, and mirror our live signals at your own position size. We never see your wallet, never sign for you, never custody a single lamport.

Don't have an API key yet? Get a free key →

Code snippets below will auto-fill with your key once you've claimed your free key on this device.

⚠ Risk warning.

Memecoin trading carries total-loss risk. The bot's signals are data, not advice; you execute every trade from your own wallet. We are not responsible for slippage, missed exits, or any losses. Stay in DRY_RUN=true for at least 7 days before flipping to live execution. Start with position sizes you can afford to lose entirely.

Table of contents

  1. 1 · Why a VPS, and why Frankfurt
  2. 2 · Vultr VPS provisioning
  3. 3 · Server hardening
  4. 4 · Wallet creation + RPC setup
  5. 5 · Install executor runtime
  6. 6 · Signal consumption (3 options)
  7. 7 · Position sizing + trade execution
  8. 8 · Risk management
  9. 9 · Deploy + run as a service
  10. 10 · Telegram alerts
  11. 11 · Troubleshooting
  12. 12 · Go-live checklist
  13. 13 · AI judgment with Nous Hermes (optional)

Part 1

Why a VPS, and why Frankfurt

Memecoin scalping lives or dies on latency. The signal you receive is already 200–500 ms old by the time it reaches your client. Adding another 100 ms of RPC latency and another 100 ms of swap submission delay turns a clean entry into a chase. The cheapest way to win that race is to run the executor on a small Linux box in a low-latency datacenter, with a 24/7 connection that doesn't depend on your laptop being awake.

Frankfurt wins for European subscribers because the major Solana RPC providers (Helius, QuickNode) operate large EU-West nodes there. Round-trip from a Frankfurt VPS to a Frankfurt RPC is typically sub-30ms. From Tokyo it's 200ms+, from US-East it's 100ms+. If you're a US trader, swap "Frankfurt" for Ashburn / New York in every instruction below — the principle is the same: colocate near your RPC.

Why not just run it on your laptop? Three reasons. (1) Your laptop sleeps, crashes, and loses Wi-Fi — every miss is a missed signal. (2) Residential broadband adds 30–80 ms of jitter and occasional spikes into the hundreds. (3) Running keys + private wallet code on your daily-driver machine is a worse attack surface than a hardened single-purpose Linux box.

Part 2

Vultr VPS provisioning

We use Vultr in this guide — straightforward, accepts crypto, has a real Frankfurt PoP. Any provider with similar specs works (Hetzner, OVH, DigitalOcean Amsterdam). What matters is the region and disk type, not the brand.

Step 2.1 — Account

  1. Sign up at vultr.com. Email confirm.
  2. Add a payment method: credit card, or crypto (BTC / ETH / USDC accepted via BitPay).

Step 2.2 — Deploy

  1. Click Deploy +Cloud Compute.
  2. Server type: High Frequency (NVMe SSD, 3+ GHz CPU). Not Regular Cloud — the IO difference matters when signing transactions under load.
  3. Location: Frankfurt.
  4. Image: Ubuntu 22.04 LTS x64.
  5. Plan: 2 vCPU · 4 GB RAM · 128 GB NVMe (~$24/mo). The executor itself uses <200 MB; the headroom is for journald, logs, dependency caches, and the occasional npm install spike.
  6. Additional features: enable IPv6 (free). Auto Backups are +$4.80/mo and worth it for production.
  7. SSH Keys → Add new. If you don't already have one, generate one on your laptop first (paste the contents of ~/.ssh/mt_vps_key.pub into Vultr):
ssh-keygen -t ed25519 -C "trader-vps-$(date +%Y%m)" -f ~/.ssh/mt_vps_key
cat ~/.ssh/mt_vps_key.pub   # paste this into Vultr "SSH Keys" during deploy
  1. Hostname: mt-executor.
  2. Click Deploy Now. Wait ~60 seconds for provisioning.

Step 2.3 — First connection

Once Vultr shows your server Running, grab its public IPv4 and connect:

ssh -i ~/.ssh/mt_vps_key root@<VPS_PUBLIC_IP>

First-login message of the day, accept the host fingerprint, then:

apt update && apt upgrade -y

Takes ~2 minutes. If linux-image-* upgraded, run reboot when finished and reconnect.

Part 3

Server hardening

These steps take 10 minutes and dramatically reduce the attack surface. Don't skip them — your wallet's private key will live on this box.

Step 3.1 — Create a non-root sudo user

adduser trader                     # set a strong password when prompted
usermod -aG sudo trader
mkdir -p /home/trader/.ssh
cp /root/.ssh/authorized_keys /home/trader/.ssh/
chown -R trader:trader /home/trader/.ssh
chmod 700 /home/trader/.ssh
chmod 600 /home/trader/.ssh/authorized_keys
echo "trader user created · log out, then back in as trader."

Log out, then back in as trader. All remaining steps run via sudo.

Step 3.2 — Disable root SSH + password auth

sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd

Test in a second terminal before closing your current session: ssh trader@<VPS_IP> should succeed; ssh root@<VPS_IP> should fail with "Permission denied".

Step 3.3 — Firewall (UFW)

ufw default deny incoming
ufw default allow outgoing
# Replace <YOUR_HOME_IP> with your own public IP — find it at https://ifconfig.me
ufw allow from <YOUR_HOME_IP>/32 to any port 22 proto tcp
ufw enable
ufw status verbose

Lock SSH to your own home IP. Outbound is unrestricted — the executor needs HTTPS out to Helius, Jupiter swap aggregator, mobile-trader.com, and Telegram. If your home IP rotates often, use 0.0.0.0/0 instead but lean harder on fail2ban + SSH key auth.

Step 3.4 — fail2ban

apt install -y fail2ban
cat > /etc/fail2ban/jail.local <<'EOF'
[sshd]
enabled = true
port    = 22
maxretry = 3
findtime = 600
bantime  = 3600
EOF
systemctl enable --now fail2ban
fail2ban-client status sshd

Step 3.5 — Unattended security updates

apt install -y unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
# Accept the default — applies security patches automatically every night.

Step 3.6 — 4 GB swap

fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
free -h   # confirm "Swap" line shows ~4Gi

Not strictly required on a 4 GB box, but cheap insurance against an OOM kill during a Node.js GC spike.

Part 4

Wallet creation + RPC setup

Step 4.1 — Why a dedicated wallet

Do not use your main wallet. Create a fresh one for the executor and fund it with exactly the SOL you're willing to put at risk. Three reasons:

  • Blast radius. If the VPS is compromised, the only loss is what's in this wallet — not your long-term holdings.
  • Bookkeeping. Every tx in/out of this wallet is the executor. Easy P&L accounting.
  • Replaceable. If you want to rotate, generate a new keypair, drain the old one, swap in the new private key, restart. 5 minutes.

Step 4.2 — Install Solana CLI

sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
# Installer prints: "Please add the following line to your shell profile..."
echo 'export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
solana --version   # expect: solana-cli 1.18.x or later

Step 4.3 — Generate the keypair

solana-keygen new --outfile ~/trading-wallet.json --no-bip39-passphrase
# Output ends with:
#   pubkey: <YOUR_NEW_PUBLIC_ADDRESS>
#   ---
#   Save this seed phrase to recover the wallet: <12 words>
#
# WRITE THE 12 WORDS DOWN ON PAPER. STORE OFFLINE. THIS IS YOUR ONLY RECOVERY.
solana-keygen pubkey ~/trading-wallet.json
solana balance --keypair ~/trading-wallet.json --url https://api.mainnet-beta.solana.com
⚠ Seed phrase — the 12 words shown during solana-keygen new are the only way to recover this wallet. Write them on paper. Store offline. Never paste them into chat, email, or a hosted notes app. If the VPS is compromised, an attacker with this seed phrase drains the wallet instantly.

Step 4.4 — Fund the wallet

From your main wallet (Phantom / Solflare), send your starting balance to the public address printed above. Recommended: 1–5 SOL to begin. You can always top up later — better to under-fund and add than overfund and regret. Verify:

solana balance --keypair ~/trading-wallet.json --url https://api.mainnet-beta.solana.com

Step 4.5 — Export the private key as base58

The executor needs the 64-byte secret key in base58 format (the same format Phantom uses). This one-liner reads the JSON keypair file, decodes it, and prints the base58 string. Run it inside a terminal you control, copy the output once into your .env, then close the terminal.

# One-liner: load the JSON keypair, encode the 64-byte secretKey as base58,
# print to stdout. COPY the output into your .env as TRADING_PRIVATE_KEY.
# Then close this terminal — DO NOT save the output to a file or paste in chat.
cd ~ && node -e "
import('bs58').then(b => {
  const fs = require('fs');
  const kp = JSON.parse(fs.readFileSync('/home/trader/trading-wallet.json'));
  const sk = Uint8Array.from(kp);
  console.log(b.default.encode(sk));
});
"
⚠ Private key handling. This string controls all funds in the wallet. Set chmod 600 .env after writing it. Never commit to git. Never paste into chat, screenshots, or pastebins. If you suspect it has leaked: send all SOL out of this wallet immediately, generate a new keypair, rotate.

Step 4.6 — Solana RPC endpoint

The public api.mainnet-beta.solana.com endpoint will rate-limit you in production. Use a paid or generous-free-tier RPC. We recommend Helius (Solana-native, Frankfurt PoP):

  1. Sign up at helius.dev.
  2. Create a project → copy the mainnet RPC URL. It looks like https://mainnet.helius-rpc.com/?api-key=....
  3. Free tier gives you 100k credits/day — enough for several thousand trades. Upgrade if you scale up.

Alternative: QuickNode also has a Frankfurt endpoint with a comparable free tier. Whichever you pick, set the URL as SOLANA_RPC_URL in .env.

Part 5

Install executor runtime

Step 5.1 — Install Node.js 20 (LTS) via nvm

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20
node --version   # expect: v20.x.x

Step 5.2 — Create the project + install dependencies

mkdir -p ~/mt-executor && cd ~/mt-executor
npm init -y
# Pin to known-good major versions. bs58@5 keeps the default-export shape used by
# the keypair-export one-liner in Step 4.5; eventsource@2 keeps the constructor
# accepting a { headers } option (v3+ changed both shapes).
npm install @solana/web3.js @jup-ag/api bs58@5 dotenv eventsource@2
mkdir -p logs

Step 5.3 — Project layout

~/mt-executor/
├── .env                ← chmod 600; secrets + tunables
├── executor.js         ← main entry (Part 6/7)
├── state.json          ← open positions + today's realized loss (created at runtime)
├── package.json
├── package-lock.json
├── logs/
│   ├── executor.log
│   └── executor-err.log
└── mt-executor.service ← systemd unit (Part 9)

Step 5.4 — .env template

Create the file, paste the template below, fill in the placeholders, then lock it down:

nano ~/mt-executor/.env
# (paste the template, edit, save with Ctrl-O / Enter / Ctrl-X)
chmod 600 ~/mt-executor/.env

The template:

# ~/mt-executor/.env  — chmod 600 this file!
# DO NOT commit. DO NOT share. The private key controls all wallet funds.

# === Required ===
TRADING_PRIVATE_KEY=<base58_secret_key_from_step_4.5>
SOLANA_RPC_URL=https://mainnet.helius-rpc.com/?api-key=<YOUR_HELIUS_KEY>
MT_API_KEY=mtk_live_<YOUR_KEY>

# === Position sizing (the most important block — read PART 7) ===
SIZING_MODE=mirror         # mirror | fixed | percent_wallet
SIZE_MULTIPLIER=0.1        # 0.1 = trade 10% of what the bot trades
FIXED_POSITION_SOL=0.25    # only used if SIZING_MODE=fixed
WALLET_PERCENT_PER_TRADE=0.05  # only used if SIZING_MODE=percent_wallet (5% of wallet/trade)
MAX_POSITION_SOL=0.5       # hard ceiling per trade regardless of multiplier
MIN_POSITION_SOL=0.05      # skip trades smaller than this (Jupiter swap fees would eat profit)

# === Risk controls ===
DAILY_LOSS_CAP_SOL=0.5     # if today's realized loss exceeds this, executor stops buying
SLIPPAGE_BPS=250           # 2.5% — increase for illiquid pairs, decrease for stable ones
DRY_RUN=true               # KEEP TRUE FOR AT LEAST 7 DAYS. Set false ONLY after a clean dry-run.

# === Telegram alerts (optional but strongly recommended) ===
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=

# === Optional secondary RPC for failover ===
SOLANA_RPC_URL_SECONDARY=

Part 6

Signal consumption — three integration paths

You don't have to automate to use mobile-trader.com. Pick the integration path that matches your comfort level. Most subscribers start with Option A for the first 1–2 weeks, then graduate to Option C once they trust the signal stream.

A · Claude Code

Human-in-the-loop. Signals appear in MCP sidebar. You review and trade manually.

Risk: lowest

B · Cursor

Same as A but inside the Cursor IDE.

Risk: lowest

C · Custom executor

Fully automated. Buys and sells from your wallet at your size, no human in the loop.

Risk: highest — read Part 8

Option A — Claude Code (recommended for week one)

Add the following to ~/.claude/mcp.json (create the file if it doesn't exist):

{
  "mcpServers": {
    "mobile-trader": {
      "url": "https://api.mobile-trader.com/v1/mcp/stream",
      "headers": {
        "Authorization": "Bearer mtk_live_<YOUR_KEY>"
      }
    }
  }
}

Restart Claude Code. Signals stream into the MCP sidebar in real time. Decide trade-by-trade whether to swap in Phantom yourself.

Option B — Cursor

Add to .cursor/config.json:

{
  "mcp": [
    {
      "name": "mobile-trader",
      "url": "https://api.mobile-trader.com/v1/mcp/stream",
      "authorization": "Bearer mtk_live_<YOUR_KEY>"
    }
  ]
}

Option C — Custom Node.js executor (this guide)

The rest of this guide assumes Option C. The full executor.js source is in Part 7. It connects to /v1/mcp/stream via Server-Sent Events, parses each signal event, computes your position size, and submits a Jupiter swap on your behalf.

If you only need the bare MCP integration (no automation), the /docs page has the API reference: endpoint schemas, rate limits, signal shape, sample REST payloads.

Part 7 — the meat

Position sizing + trade execution

Step 7.1 — The point of position sizing

The signal stream tells you what the bot is trading. It does not tell you what you should trade. Your wallet, your risk tolerance, your size. The executor's job is to translate the bot's signal into a swap your wallet can survive.

Step 7.2 — The three sizing modes

Mode 1 — Mirror (default). Trade a scaled fraction of what the bot trades.

SIZING_MODE=mirror
SIZE_MULTIPLIER=0.1   # the bot enters at 1.0 SOL → you enter at 0.1 SOL
SIZE_MULTIPLIER=0.5   # half size
SIZE_MULTIPLIER=2.0   # double — NOT recommended; risk doubles too

Mirror is the right default. As the bot's entry sizing reflects its conviction, your trades scale with conviction too.

Mode 2 — Fixed. Every entry is the same SOL amount.

SIZING_MODE=fixed
FIXED_POSITION_SOL=0.25   # every entry is exactly 0.25 SOL

Simpler accounting. Loses the "conviction scaling" benefit of mirror — every signal is treated equally.

Mode 3 — Percent wallet. Each entry is a % of current wallet balance.

SIZING_MODE=percent_wallet
WALLET_PERCENT_PER_TRADE=0.05   # 5% of wallet per entry

Auto-scales: wallet grows → bigger trades, wallet shrinks → smaller trades. Most resilient to drawdowns. Recommended once you have a feel for the bot's win rate and want compounding behaviour.

Step 7.3 — Always-applied caps

Regardless of mode, every entry is clipped by:

  • MAX_POSITION_SOL — hard ceiling. If your computed size exceeds it, the entry is capped at this value. Defense against runaway sizing if a signal carries an unusual entrySOL field.
  • MIN_POSITION_SOL — floor. If your computed size is below it, the entry is skipped entirely. Tiny trades get eaten by Jupiter swap fees (≈ 0.001 SOL minimum per swap) and rarely turn a profit.
  • Wallet balance check — if you don't have target + 0.01 SOL available, the entry is skipped and you get a Telegram alert to top up.
  • DAILY_LOSS_CAP_SOL — once cumulative realized loss today exceeds this, the executor stops opening new positions (existing SELLs still process). Resets at 00:00 UTC.

Step 7.4 — Full executor.js

Save the following as ~/mt-executor/executor.js. Top-to-bottom — no ... placeholders. Every helper, every error path. Read it once before you save it; you should understand each block.

// ~/mt-executor/executor.js  (Node.js 20+, ESM)
//
// Reads signals from your mobile-trader.com MCP SSE stream and executes BUY / SELL
// swaps on Solana through the Jupiter swap aggregator using YOUR wallet.
//
// SAFETY: while DRY_RUN=true (default), this script logs decisions only — no transactions
// are signed or submitted. Run for a full week before flipping to live execution.

import 'dotenv/config';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
  Connection, Keypair, VersionedTransaction, PublicKey,
} from '@solana/web3.js';
import { createJupiterApiClient } from '@jup-ag/api';
import bs58 from 'bs58';
import EventSource from 'eventsource';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATE_FILE = path.join(__dirname, 'state.json');
const LOG_FILE = path.join(__dirname, 'logs', 'executor.log');
const SOL_MINT = 'So11111111111111111111111111111111111111112';

const cfg = {
  rpc: process.env.SOLANA_RPC_URL,
  rpcSecondary: process.env.SOLANA_RPC_URL_SECONDARY || null,
  apiKey: process.env.MT_API_KEY,
  sizingMode: process.env.SIZING_MODE || 'mirror',
  sizeMultiplier: parseFloat(process.env.SIZE_MULTIPLIER || '0.1'),
  fixedSol: parseFloat(process.env.FIXED_POSITION_SOL || '0.25'),
  walletPct: parseFloat(process.env.WALLET_PERCENT_PER_TRADE || '0.05'),
  maxPositionSol: parseFloat(process.env.MAX_POSITION_SOL || '0.5'),
  minPositionSol: parseFloat(process.env.MIN_POSITION_SOL || '0.05'),
  dailyLossCapSol: parseFloat(process.env.DAILY_LOSS_CAP_SOL || '0.5'),
  slippageBps: parseInt(process.env.SLIPPAGE_BPS || '250', 10),
  dryRun: (process.env.DRY_RUN || 'true') === 'true',
  tgToken: process.env.TELEGRAM_BOT_TOKEN || '',
  tgChat: process.env.TELEGRAM_CHAT_ID || '',
};

// === Wallet + connections ===
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.TRADING_PRIVATE_KEY));
let connection = new Connection(cfg.rpc, { commitment: 'confirmed' });
const jupiterApi = createJupiterApiClient();  // Jupiter swap aggregator client

// === State (open positions + today's realized loss) ===
let state = { positions: {}, dayUtc: dayUtcStr(), realizedLossSol: 0, lastTgTs: 0 };
async function loadState() {
  try { state = JSON.parse(await fs.readFile(STATE_FILE, 'utf8')); }
  catch { /* fresh start */ }
  if (state.dayUtc !== dayUtcStr()) { state.dayUtc = dayUtcStr(); state.realizedLossSol = 0; }
}
async function saveState() { await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2)); }
function dayUtcStr() { return new Date().toISOString().slice(0, 10); }

// === Logging ===
async function log(...parts) {
  const line = `[${new Date().toISOString()}] ${parts.join(' ')}\n`;
  process.stdout.write(line);
  try { await fs.appendFile(LOG_FILE, line); } catch {}
}

// === Telegram (throttled to 1 msg / 2s) ===
async function telegram(msg) {
  if (!cfg.tgToken || !cfg.tgChat) return;
  const now = Date.now();
  if (now - state.lastTgTs < 2000) return;
  state.lastTgTs = now;
  try {
    await fetch(`https://api.telegram.org/bot${cfg.tgToken}/sendMessage`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ chat_id: cfg.tgChat, text: msg, disable_web_page_preview: true }),
    });
  } catch { /* never throw from notifier */ }
}

// === Position sizing — the heart of the executor ===
async function computeTargetSize(signal) {
  let target;
  if (cfg.sizingMode === 'fixed') {
    target = cfg.fixedSol;
  } else if (cfg.sizingMode === 'percent_wallet') {
    const balLamports = await connection.getBalance(keypair.publicKey);
    target = (balLamports / 1e9) * cfg.walletPct;
  } else {
    // 'mirror' (default): scale the bot's entry size by your multiplier
    const botEntry = Number(signal.entrySOL || 0);
    if (!botEntry || botEntry <= 0) return 0;
    target = botEntry * cfg.sizeMultiplier;
  }
  // Hard caps
  if (target > cfg.maxPositionSol) target = cfg.maxPositionSol;
  if (target < cfg.minPositionSol) return 0;  // skip
  return target;
}

// === Per-token in-flight lock (avoid overlapping BUYs for the same token) ===
const _locks = new Map();
async function withLock(token, fn) {
  while (_locks.has(token)) await _locks.get(token);
  let resolve;
  const p = new Promise(r => { resolve = r; });
  _locks.set(token, p);
  try { return await fn(); }
  finally { _locks.delete(token); resolve(); }
}

// === Day-rollover helper — call from every entry point that uses the daily cap ===
function ensureDayFresh() {
  const today = dayUtcStr();
  if (state.dayUtc !== today) { state.dayUtc = today; state.realizedLossSol = 0; }
}

// === BUY flow ===
async function executeBuy(signal) {
  return withLock(signal.ca, async () => {
    ensureDayFresh();
    // 'target' is 'let' (not const) so the optional Hermes layer in Part 13 can scale it.
    let target = await computeTargetSize(signal);
    if (target <= 0) { await log('SKIP_BUY', signal.symbol, 'below MIN_POSITION_SOL or no entrySOL'); return; }

    if (state.realizedLossSol >= cfg.dailyLossCapSol) {
      await log('SKIP_BUY', signal.symbol, 'daily loss cap hit:', state.realizedLossSol.toFixed(3));
      return;
    }

    const balLamports = await connection.getBalance(keypair.publicKey);
    const balSol = balLamports / 1e9;
    if (balSol < target + 0.01) {
      await log('SKIP_BUY', signal.symbol, `insufficient balance ${balSol.toFixed(3)}`);
      await telegram(`⚠️ SKIP BUY ${signal.symbol}: balance ${balSol.toFixed(3)} SOL < ${(target + 0.01).toFixed(3)} required`);
      return;
    }

    // Quote via Jupiter swap aggregator
    let quote;
    try {
      quote = await jupiterApi.quoteGet({
        inputMint: SOL_MINT,
        outputMint: signal.ca,
        amount: Math.floor(target * 1e9),
        slippageBps: cfg.slippageBps,
      });
    } catch (e) {
      await log('ERR_QUOTE', signal.symbol, e.message);
      return;
    }
    const outAmount = Number(quote.outAmount);

    if (cfg.dryRun) {
      await log('DRY_RUN_BUY', signal.symbol, `${target.toFixed(4)} SOL -> ${outAmount} tokens`);
      return;
    }

    // Build + sign + send via Jupiter swap route
    const swap = await jupiterApi.swapPost({
      swapRequest: { quoteResponse: quote, userPublicKey: keypair.publicKey.toBase58(), wrapAndUnwrapSol: true },
    });
    const tx = VersionedTransaction.deserialize(Buffer.from(swap.swapTransaction, 'base64'));
    tx.sign([keypair]);
    let sig;
    try {
      sig = await connection.sendTransaction(tx, { maxRetries: 3, skipPreflight: false });
      await connection.confirmTransaction(sig, 'confirmed');
    } catch (e) {
      await log('ERR_SEND_BUY', signal.symbol, e.message);
      await maybeFailoverRpc(e);
      return;
    }

    state.positions[signal.ca] = {
      symbol: signal.symbol, entrySol: target, tokensRemaining: outAmount,
      entryTs: Date.now(), entrySig: sig,
    };
    await saveState();
    await log('BUY_FILLED', signal.symbol, target.toFixed(4), 'SOL, tx', sig);
    await telegram(`✅ BUY ${signal.symbol} · ${target.toFixed(4)} SOL\nhttps://solscan.io/tx/${sig}`);
  });
}

// === SELL flow ===
async function executeSell(signal) {
  return withLock(signal.ca, async () => {
    ensureDayFresh();
    const pos = state.positions[signal.ca];
    if (!pos) { await log('SKIP_SELL', signal.symbol, 'no local position (joined mid-cycle)'); return; }

    const sellPct = Math.min(100, Math.max(1, Number(signal.sellPercent || 100)));
    const tokensToSell = Math.floor(pos.tokensRemaining * (sellPct / 100));
    if (tokensToSell <= 0) { await log('SKIP_SELL', signal.symbol, 'zero tokens after pct calc'); return; }

    let quote;
    try {
      quote = await jupiterApi.quoteGet({
        inputMint: signal.ca,
        outputMint: SOL_MINT,
        amount: tokensToSell,
        slippageBps: cfg.slippageBps,
      });
    } catch (e) {
      await log('ERR_QUOTE_SELL', signal.symbol, e.message);
      return;
    }
    const outSol = Number(quote.outAmount) / 1e9;
    const pctOfEntry = (pos.entrySol * (sellPct / 100));
    const realizedPnlSol = outSol - pctOfEntry;

    if (cfg.dryRun) {
      await log('DRY_RUN_SELL', signal.symbol, `${sellPct}% -> ${outSol.toFixed(4)} SOL (pnl ${realizedPnlSol >= 0 ? '+' : ''}${realizedPnlSol.toFixed(4)})`);
      return;
    }

    const swap = await jupiterApi.swapPost({
      swapRequest: { quoteResponse: quote, userPublicKey: keypair.publicKey.toBase58(), wrapAndUnwrapSol: true },
    });
    const tx = VersionedTransaction.deserialize(Buffer.from(swap.swapTransaction, 'base64'));
    tx.sign([keypair]);
    let sig;
    try {
      sig = await connection.sendTransaction(tx, { maxRetries: 3, skipPreflight: false });
      await connection.confirmTransaction(sig, 'confirmed');
    } catch (e) {
      await log('ERR_SEND_SELL', signal.symbol, e.message);
      await maybeFailoverRpc(e);
      return;
    }

    pos.tokensRemaining -= tokensToSell;
    pos.entrySol -= pctOfEntry;
    if (realizedPnlSol < 0) state.realizedLossSol += -realizedPnlSol;
    if (pos.tokensRemaining < 1) delete state.positions[signal.ca];
    await saveState();
    await log('SELL_FILLED', signal.symbol, `${sellPct}%, +${outSol.toFixed(4)} SOL (pnl ${realizedPnlSol.toFixed(4)})`, sig);
    await telegram(`💰 SELL ${signal.symbol} ${sellPct}% · pnl ${realizedPnlSol >= 0 ? '+' : ''}${realizedPnlSol.toFixed(4)} SOL\nhttps://solscan.io/tx/${sig}`);
  });
}

// === RPC failover (Helius primary -> secondary on persistent errors) ===
let _failoverCount = 0;
async function maybeFailoverRpc(err) {
  if (!cfg.rpcSecondary) return;
  if (!/timeout|fetch failed|503|429/i.test(err.message || '')) return;
  _failoverCount++;
  if (_failoverCount < 3) return;
  await log('RPC_FAILOVER', 'switching to secondary RPC after 3 errors');
  connection = new Connection(cfg.rpcSecondary, { commitment: 'confirmed' });
  _failoverCount = 0;
}

// === SSE consumer with exponential backoff ===
function connectStream() {
  const url = 'https://api.mobile-trader.com/v1/mcp/stream';
  const es = new EventSource(url, { headers: { Authorization: `Bearer ${cfg.apiKey}` } });

  es.addEventListener('ready', (evt) => log('STREAM_READY', evt.data));
  es.addEventListener('signal', async (evt) => {
    let s;
    try { s = JSON.parse(evt.data); } catch { await log('BAD_JSON', evt.data); return; }
    // Normalize: the Worker uses 'type' (BUY/SELL); the schema doc shows 'event'.
    const ev = (s.type || s.event || '').toUpperCase();
    if (ev === 'BUY') return executeBuy(s).catch(e => log('ERR_BUY', e.message));
    if (ev === 'SELL') return executeSell(s).catch(e => log('ERR_SELL', e.message));
  });

  let backoffMs = 1000;
  es.onerror = () => {
    log('STREAM_ERR', `reconnecting in ${backoffMs}ms`);
    try { es.close(); } catch {}
    setTimeout(connectStream, backoffMs);
    backoffMs = Math.min(backoffMs * 2, 30_000);
  };
  es.onopen = () => { backoffMs = 1000; };
}

// === Main ===
(async () => {
  await loadState();
  await log('STARTUP', `mode=${cfg.dryRun ? 'DRY_RUN' : 'LIVE'} sizing=${cfg.sizingMode} mult=${cfg.sizeMultiplier} max=${cfg.maxPositionSol} cap=${cfg.dailyLossCapSol}`);
  await telegram(`🚀 mt-executor started · ${cfg.dryRun ? 'DRY_RUN' : 'LIVE'} mode · wallet ${keypair.publicKey.toBase58().slice(0, 8)}...`);
  connectStream();
})();

Step 7.5 — REST polling alternative

If SSE is blocked by your firewall (rare, but happens on some corporate networks), poll the REST endpoint instead. Note: REST polling adds 2–5 seconds of latency vs SSE — your fills will be worse on fast-moving tokens.

// Alternative to SSE — REST polling every 5s.
// Use only if your environment blocks long-lived connections.
import 'dotenv/config';
let lastSeenTs = '';
setInterval(async () => {
  const r = await fetch('https://api.mobile-trader.com/v1/mcp/recent?limit=20', {
    headers: { Authorization: `Bearer ${process.env.MT_API_KEY}` },
  });
  const j = await r.json();
  for (const sig of (j.signals || []).reverse()) {
    if (sig.ts <= lastSeenTs) continue;
    lastSeenTs = sig.ts;
    // dispatch sig.type === 'BUY' or 'SELL'
  }
}, 5000);

Step 7.6 — Edge cases handled in the code above

  • Overlapping BUYs for the same token. Per-token mutex (withLock) — the second signal waits for the first to settle.
  • SELL for an unowned position. Logged + skipped. Common when you subscribe mid-cycle — the bot already opened the position before you joined; you can't sell what you don't have.
  • Jupiter swap quote "slippage exceeded". The swap simply fails — caught in the catch, logged, and skipped. Bump SLIPPAGE_BPS if it happens often.
  • RPC timeout. 3 consecutive errors trigger a failover to SOLANA_RPC_URL_SECONDARY if set.
  • Process restart mid-trade. state.json is persisted after every BUY/SELL — restart resumes with full open-position knowledge.

Part 8 — the most important section

Risk management

Memecoin scalping will produce losing trades. The question is not whether you'll lose on any individual signal — it's whether your system survives the bad days. The seven controls below exist to make sure the answer is yes.

1 · DRY_RUN mode (non-negotiable for week one)

Set DRY_RUN=true in your .env. The executor logs every decision it would have made — no transactions are signed, no SOL leaves your wallet. Run for at least 7 days. Watch the log. Trends to look for:

  • How often SKIP_BUY fires and why (balance? cap? min size?).
  • What positions you would have opened — do they make sense to you?
  • Whether you'd be comfortable with the realized P&L pattern.
  • Any ERR_* entries — diagnose them before going live.
# Watch dry-run output for 7 days. You want to see:
#   1. STREAM_READY (within seconds of start)
#   2. DRY_RUN_BUY / DRY_RUN_SELL lines as signals come in
#   3. SKIP_BUY / SKIP_SELL with reasons for any signals that filter out
#   4. NO ERR_* lines (or rare/transient ones only)
tail -f ~/mt-executor/logs/executor.log | grep -v keepalive

2 · DAILY_LOSS_CAP_SOL — the hard kill switch

Once today's cumulative realized loss exceeds this value, the executor stops opening new BUYs. SELLs on existing positions still process — the bot still gets to exit you cleanly. Counter resets at 00:00 UTC.

Recommended starting value: 15–20% of your starting wallet balance. If you funded 2 SOL, set DAILY_LOSS_CAP_SOL=0.4. Tightening it further is fine; loosening it past 30% means a single very bad day can take you out.

3 · MAX_POSITION_SOL — runaway protection

Defends against any single trade going unexpectedly large — whether due to a multiplier mistake, an anomalous signal, or a balance miscalc. Hard upper limit on any single entry.

Recommended: no more than 30% of your wallet per trade. With a 2 SOL wallet, set MAX_POSITION_SOL=0.5.

4 · MIN_POSITION_SOL — skip-the-dust

Trades smaller than this are skipped entirely. Jupiter swap aggregator fees and Solana network fees together can hit ~0.001 SOL minimum — for a 0.03 SOL trade that's ~3% friction before you even start. The economics rarely justify it.

Default: 0.05 SOL.

5 · SLIPPAGE_BPS — fill quality vs miss rate

Slippage tolerance in basis points (1/100ths of a percent). Lower = better fills but more failed trades. Higher = more fills but worse prices on illiquid pools.

  • 100 bps (1%) — only safe on deeply liquid tokens. You'll miss most memecoin entries.
  • 250 bps (2.5%) — recommended default. Good fill rate, acceptable price impact.
  • 500 bps (5%) — for very illiquid pools. You will get filled but expect a 3–5% worse price than the signal price.

6 · Wallet-balance alerts

The code in Part 7 sends a Telegram alert any time a BUY is skipped for insufficient balance. Don't ignore these — every skipped entry is a missed compound. Top up your trading wallet promptly when alerts fire.

7 · RPC failover

Set SOLANA_RPC_URL_SECONDARY in your .env to a second RPC provider. After 3 consecutive errors on the primary, the executor automatically switches over. Combination we recommend:

  • Primary: Helius Frankfurt.
  • Secondary: QuickNode Frankfurt (or a second Helius endpoint).

Part 9

Deploy + run as a service

Step 9.1 — Smoke test in the foreground first

cd ~/mt-executor
node executor.js
# Expect:
#   STARTUP mode=DRY_RUN sizing=mirror ...
#   STREAM_READY {"tier":"weekly"}
# Then signals as they arrive. Ctrl-C to stop.

Step 9.2 — systemd unit

Save as ~/mt-executor/mt-executor.service:

[Unit]
Description=Mobile Trader executor
After=network.target

[Service]
Type=simple
User=trader
WorkingDirectory=/home/trader/mt-executor
# Replace v20.19.0 with your actual installed nvm node version (run "which node" to confirm)
ExecStart=/home/trader/.nvm/versions/node/v20.19.0/bin/node /home/trader/mt-executor/executor.js
Restart=on-failure
RestartSec=10
StandardOutput=append:/home/trader/mt-executor/logs/executor.log
StandardError=append:/home/trader/mt-executor/logs/executor-err.log
Environment="NODE_ENV=production"

[Install]
WantedBy=multi-user.target

Confirm your actual node path with which node and substitute it into ExecStart. nvm versions move over time — hard-coding the path means systemd won't break the next time you nvm install.

Step 9.3 — Install + enable

# As root (sudo):
sudo cp ~/mt-executor/mt-executor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable mt-executor
sudo systemctl start mt-executor

# Monitor:
sudo systemctl status mt-executor
sudo journalctl -u mt-executor -f         # live tail
tail -f ~/mt-executor/logs/executor.log   # app-level log

# After editing .env, restart:
sudo systemctl restart mt-executor

Step 9.4 — Log rotation

The executor appends to logs/executor.log indefinitely; rotate via logrotate:

# /etc/logrotate.d/mt-executor — keep 14 days of compressed logs
/home/trader/mt-executor/logs/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    copytruncate
    su trader trader
}

Part 10

Telegram alerts

The executor in Part 7 already includes Telegram notifications for: startup, BUY fills, SELL fills, balance-too-low skips, and any unhandled error. Wire it up:

# 1. Open Telegram, search @BotFather, /start, /newbot
#    Pick a name + username — BotFather replies with: "Use this token to access the HTTP API: 12345:ABC..."
#    -> set this as TELEGRAM_BOT_TOKEN in your .env

# 2. Send "/start" to YOUR new bot in Telegram (so it can message you)

# 3. Find your chat_id:
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates"
# Look for "chat":{"id":<NUMBER>, ...} in the response.
# -> set <NUMBER> as TELEGRAM_CHAT_ID in your .env

# 4. Test it:
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage" \
  -H "Content-Type: application/json" \
  -d '{"chat_id":"<YOUR_CHAT_ID>","text":"mt-executor wired ✓"}'

The executor self-throttles to 1 message per 2 seconds, so a burst of signals won't trip Telegram's rate limit.

Part 11

Troubleshooting

Symptom Likely cause Fix
"Insufficient SOL"Wallet drained or balance below target + 0.01.Top up wallet. Check MIN_POSITION_SOL isn't set absurdly high.
"Slippage exceeded"Pool price moved between quote and swap submission.Bump SLIPPAGE_BPS by 100–200, or accept the miss on illiquid pairs.
"Tx timeout" / "blockhash not found"RPC degraded, network congestion.Confirm SOLANA_RPC_URL_SECONDARY is set. Check Helius status page. Failover should auto-trigger after 3 errs.
Signal received but no tradeDRY_RUN=true, daily loss cap reached, or computed size below MIN_POSITION_SOL.Check log for the matching SKIP_BUY or DRY_RUN_BUY line — it states the reason.
MCP stream disconnects repeatedlyNetwork blip, API key expired, or firewall.Auto-reconnect handles blips. If persistent: curl -H "Authorization: Bearer mtk_live_..." https://api.mobile-trader.com/v1/mcp/recent — 401 means key expired (re-subscribe).
SELL signal logged "no local position"You subscribed mid-cycle — the bot opened the position before you joined.Expected behaviour. Wait for the next fresh BUY signal to start tracking that token.
Executor dies, doesn't restartsystemd not enabled, or repeated startup crash.systemctl status mt-executor. If "disabled": systemctl enable mt-executor. If "failed": journalctl -u mt-executor -n 50 for the stack trace.
"EADDR not bound" / "ECONNREFUSED" to api.mobile-trader.comUFW or VPS provider blocked outbound 443.curl https://api.mobile-trader.com/v1/health — should return JSON. If timeout, check ufw status.
Tx lands on Solscan but executor logs errorConfirmation timeout — tx succeeded but RPC didn't confirm in time.Check Solscan with the printed sig. If confirmed, manually edit state.json to record the fill, then restart.

Part 12

Go-live checklist

Before flipping DRY_RUN=false, every one of these must be true. If you can't check a box, don't go live yet.

  • DRY_RUN=true ran for at least 7 days. You reviewed the log and the would-have trades make sense to you.
  • Trading wallet funded with a starting balance you can afford to lose entirely.
  • SIZE_MULTIPLIER set conservative (0.1–0.3) for the first month.
  • MAX_POSITION_SOL ≤ 30% of wallet balance.
  • DAILY_LOSS_CAP_SOL ≤ 20% of wallet balance.
  • SLIPPAGE_BPS in the 200–300 range.
  • Telegram alerts tested — you received a "🚀 mt-executor started" message on the last restart.
  • systemd service enabled. systemctl status mt-executor shows "active (running)".
  • Logs flowing to ~/mt-executor/logs/. Last log line is < 60 seconds old.
  • MT_API_KEY unexpired. curl -H "Authorization: Bearer mtk_live_..." https://api.mobile-trader.com/v1/mcp/recent returns 200.
  • Trading wallet imported into Phantom / Solflare as watch-only (public address) so you can monitor balance without exposing the key.
  • You've read Part 8 again.

Going live

  1. Edit .env: DRY_RUN=false.
  2. sudo systemctl restart mt-executor.
  3. Watch the log for the next BUY signal. Verify the resulting tx on Solscan from the printed sig.
  4. Watch the first 5 trades very carefully. Confirm fill price, slippage, and state.json updates match your expectations.
  5. After 24 hours of clean live operation, you can leave it running. Continue to check the Telegram feed daily.

Part 13 — optional / advanced

AI judgment layer with Nous Hermes

Most subscribers run the executor in pure-mirror mode: every BUY signal triggers an entry. That's fine — our signal stream is already heavily filtered before it reaches you. But some subscribers want a second layer of judgment between the signal and the swap, applying their own criteria on top of the bot's.

Nous Hermes is an open-source LLM family from Nous Research, fine-tuned to follow instructions and return structured output reliably. We use it here as an optional vote — Hermes scores each incoming signal BUY/SKIP with a confidence number, and the executor either skips the trade, sizes it normally, or scales it down based on the score.

Skip Part 13 entirely if you just want to mirror the bot. Come back to it later when you want extra filtering.

Step 13.1 — Three integration paths

  • OpenRouter API — recommended. Easiest setup, pay-per-token (≈ $1–5/month at typical signal volumes). Hermes 3 and Hermes 4 both available with a single env-var swap.
  • Together AI — alternative hosted API. Use if OpenRouter rate-limits or you prefer a different vendor.
  • Ollama (self-hosted on the VPS) — zero per-token cost, but Hermes 3 8B needs about 12 GB RAM — step up from the 4 GB Vultr plan first.

Step 13.2 — OpenRouter signup (recommended path)

  1. Sign up at openrouter.ai.
  2. Top up $10 — at typical signal volumes that lasts months.
  3. Settings → Keys → Create Key. Copy it. The string looks like sk-or-v1-....
  4. Browse the model list and pick a Hermes variant:
    • nousresearch/hermes-3-llama-3.1-8b — fastest, cheapest, sometimes free-tier eligible.
    • nousresearch/hermes-3-llama-3.1-70b — recommended balance of cost and reasoning quality.
    • nousresearch/hermes-3-llama-3.1-405b — strongest reasoning, ~10× the cost, slower.
    • nousresearch/hermes-4-... — newer family; check OpenRouter's model page for current IDs.

Step 13.3 — Add the Hermes env vars

# Append to ~/mt-executor/.env (under the existing settings):

# === Optional: Nous Hermes AI judgment layer (Part 13) ===
HERMES_ENABLED=true                                # set false to disable without removing wiring
OPENROUTER_API_KEY=sk-or-v1-...                    # from openrouter.ai
HERMES_MODEL=nousresearch/hermes-3-llama-3.1-70b   # balanced; swap for 8b (cheap) or 405b (smartest)
HERMES_MIN_CONFIDENCE=6                            # skip if Hermes scores below this (0-10)
HERMES_SCALE_BY_CONFIDENCE=true                    # multiply position size by (confidence / 10)

Step 13.4 — Add Hermes config to executor.js

Append to the cfg object near the top of the file:

// Append to the cfg object near the top of executor.js:
hermesEnabled: (process.env.HERMES_ENABLED || 'false') === 'true',
openrouterKey: process.env.OPENROUTER_API_KEY || '',
hermesModel: process.env.HERMES_MODEL || 'nousresearch/hermes-3-llama-3.1-70b',
hermesMinConfidence: parseInt(process.env.HERMES_MIN_CONFIDENCE || '6', 10),
hermesScaleByConfidence: (process.env.HERMES_SCALE_BY_CONFIDENCE || 'false') === 'true',

Step 13.5 — The Hermes judgment function

Add this block to executor.js, above executeBuy. It defines hermesJudge() (calls OpenRouter, parses JSON), buildHermesPrompt() (customize the criteria to your taste), and shows the four-line patch inside executeBuy that wires Hermes into the flow:

// === Hermes judgment via OpenRouter ===
// Every BUY signal gets reviewed by Hermes before we size and submit the swap.
// Customize buildHermesPrompt() below to match your own conviction model.

async function hermesJudge(signal) {
  if (!cfg.hermesEnabled || !cfg.openrouterKey) {
    return { action: 'BUY', confidence: 10, reason: 'hermes disabled' };
  }
  const userPrompt = buildHermesPrompt(signal);
  try {
    const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${cfg.openrouterKey}`,
        'Content-Type': 'application/json',
        'HTTP-Referer': 'https://mobile-trader.com',
        'X-Title': 'mt-executor',
      },
      body: JSON.stringify({
        model: cfg.hermesModel,
        messages: [{ role: 'user', content: userPrompt }],
        response_format: { type: 'json_object' },
        max_tokens: 200,
        temperature: 0.2,
      }),
    });
    if (!r.ok) {
      const txt = await r.text().catch(() => '');
      await log('HERMES_HTTP_ERR', r.status, txt.slice(0, 200));
      return { action: 'BUY', confidence: 5, reason: 'hermes http err - allowing' };
    }
    const j = await r.json();
    const content = j.choices?.[0]?.message?.content || '{}';
    const parsed = JSON.parse(content);
    if (!parsed.action || (parsed.action !== 'BUY' && parsed.action !== 'SKIP')) {
      return { action: 'BUY', confidence: 5, reason: 'hermes returned malformed - allowing' };
    }
    return parsed;
  } catch (e) {
    await log('HERMES_ERR', e.message);
    return { action: 'BUY', confidence: 5, reason: 'hermes exception - allowing' };
  }
}

function buildHermesPrompt(signal) {
  return `You are evaluating a memecoin BUY signal from a Solana trading bot for a subscriber.
The bot has already vetted the token; your job is to apply an additional filter.

Signal:
- Token: ${signal.symbol} (${signal.ca})
- Entry price USD: ${signal.entryPriceUsd}
- Entry market cap USD: ${signal.entryMC}
- Bot position size: ${signal.entrySOL} SOL
- Discovery source: ${signal.discoverySource}

Your criteria (edit to match your own preferences):
- Prefer market caps under $2M (more upside left).
- Prefer signals from "scanner" or "watchlist" sources.
- Skip if MC > $10M (likely too late in the move).
- Skip if entry size suggests low conviction (under 0.3 SOL).

Respond with ONLY a JSON object, no prose:
{"action": "BUY" or "SKIP", "confidence": 0-10, "reason": "one short sentence"}`;
}

// Inside executeBuy(), insert this block immediately AFTER the wallet-balance
// check (the if-block that logs 'insufficient balance') and BEFORE the line
// 'let quote;'. The block reads/mutates the existing 'target' variable, which
// is already declared with 'let' in the executor.js above for exactly this reason.
//
//   const judgment = await hermesJudge(signal);
//   await log('HERMES', signal.symbol, judgment.action, 'conf=' + judgment.confidence, judgment.reason);
//   if (judgment.action !== 'BUY') {
//     await telegram('Hermes vetoed ' + signal.symbol + ': ' + judgment.reason);
//     return;
//   }
//   if (judgment.confidence < cfg.hermesMinConfidence) {
//     await log('SKIP_BUY', signal.symbol, 'hermes conf ' + judgment.confidence + ' < min ' + cfg.hermesMinConfidence);
//     return;
//   }
//   if (cfg.hermesScaleByConfidence) {
//     const target0 = target;
//     target = target * (judgment.confidence / 10);
//     await log('HERMES_SCALE', signal.symbol, target0.toFixed(4), '->', target.toFixed(4));
//   }

Notice that every failure path (Hermes disabled, OpenRouter down, malformed JSON response, exception) defaults to action: 'BUY', confidence: 5. The executor never blocks the trade on a failed Hermes call — if you want stricter behaviour, change the defaults to action: 'SKIP'.

Step 13.6 — Ollama alternative (self-hosted)

Prefer not to pay per token? Run Hermes locally via Ollama. Requires a beefier VPS — about 12 GB RAM minimum for Hermes 3 8B. Upgrade your Vultr plan to the 4 vCPU / 16 GB High Frequency tier first.

# Self-host Hermes on the VPS via Ollama. Free per-token, but Hermes 3 8B
# needs ~12 GB RAM minimum — upgrade your Vultr plan to 4 vCPU / 16 GB first.

curl -fsSL https://ollama.com/install.sh | sh

# Pull a Nous Hermes model. 8B is the smallest VPS-friendly size:
ollama pull hermes3:8b

# Larger models if your VPS has the RAM:
# ollama pull hermes3:70b      # needs ~64 GB RAM

# Daemon listens on localhost:11434 by default. Start it as a systemd unit
# so it stays up across reboots:
sudo systemctl enable --now ollama

# Smoke-test the model:
curl http://localhost:11434/api/chat -d '{
  "model": "hermes3:8b",
  "messages": [{"role":"user","content":"Reply with JSON only: {\"ok\": true}"}],
  "format": "json",
  "stream": false
}'

And the corresponding fetch swap inside hermesJudge():

// If using Ollama instead of OpenRouter, replace the fetch block inside
// hermesJudge() with the Ollama API shape:

const r = await fetch('http://localhost:11434/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: cfg.hermesModel,         // e.g. 'hermes3:8b'
    messages: [{ role: 'user', content: userPrompt }],
    format: 'json',                  // Ollama's JSON-mode hint
    stream: false,
  }),
});
if (!r.ok) return { action: 'BUY', confidence: 5, reason: 'ollama http err - allowing' };
const j = await r.json();
const content = j.message?.content || '{}';
const parsed = JSON.parse(content);
return parsed;

// And set HERMES_MODEL=hermes3:8b in .env instead of the OpenRouter model ID.

Step 13.7 — Cost considerations

  • OpenRouter Hermes 3 70B — roughly $0.30–0.40 per 1M tokens. A typical signal evaluation uses ~250 tokens (prompt + reply). At 50 signals/day that's a few cents/day — ~$1–2/month.
  • OpenRouter Hermes 3 405B — roughly $3–5 per 1M tokens. ~$10/month at similar volume. Worth it if you want stronger reasoning.
  • Ollama self-hosted — $0 per token, but the VPS cost goes from $24/mo to ~$60/mo for an adequate 16 GB plan, and you lose CPU headroom for the executor itself.
  • Latency — Hermes adds ~0.5–2 s per signal evaluation (OpenRouter) or ~1–5 s (Ollama, depending on model size). Each signal still completes well within the bot's expected entry window.

Step 13.8 — Risk caveats

⚠ Hermes is a filter, not an oracle.
  • LLMs can be confidently wrong — Hermes will reject some winning signals. There is no free lunch.
  • Run with DRY_RUN=true for a full week after enabling Hermes. Compare its calls against the pure-mirror baseline before going live.
  • Your DAILY_LOSS_CAP_SOL, MAX_POSITION_SOL, and MIN_POSITION_SOL still apply on top of Hermes — they're not bypassed.
  • If you change the prompt, treat it like a code change: dry-run again to make sure the new criteria behave as expected.
  • OpenRouter's terms and Nous Research's licenses apply to your usage — review both before commercial use.

Need a key or have a question?

Full API reference and signal schema: /docs. FAQ: /faq.