accueil

Guides

Bot de tenue de marché

Un bot complet de tenue de marché pour Seeryx en TypeScript et Python : coter autour du prix médian, réajuster, gérer les exécutions, gagner des récompenses.

La boucle

Un teneur de marché maintient un ordre d’achat et un ordre de vente dans le carnet et gagne l’écart et la remise maker lorsque d’autres négocient contre lui. Sur Seeryx, deux ordres d’achat suffisent : un ordre d’achat Baisse à q est un ordre de vente Hausse à 100 − q. Le bot ci-dessous cote la manche BTC de 5 minutes en cours :

  1. Lire la manche, son carnet Hausse et votre compte (positions et ordres en attente).
  2. Fixer les prix des deux ordres quelques cents de part et d’autre du prix médian entre le meilleur prix acheteur et le meilleur prix vendeur. Sans carnet à deux côtés, il n’y a pas de prix médian : le bot attend.
  3. Réajuster ses prix uniquement lorsque le prix médian a bougé ou qu’un ordre a été exécuté : annuler les ordres de la manche en une seule requête, placer les deux ordres en un seul lot, en Post-only.
  4. Gérer les exécutions. Signaler ce qui a changé, cesser d’acheter un côté dès que le bot en détient assez, et fusionner les paires Hausse et Baisse pour les reconvertir en liquidités.
  5. S’arrêter avant la clôture. 15 secondes avant la fin, le bot annule tout et attend la manche suivante. Ce qu’il détient encore est versé automatiquement.

Le bot

Lancez-le à côté de seeryx, message et chain des pages précédentes : npx tsx bot.ts ou python bot.py. Il se connecte, ouvre une session de trading (une signature du portefeuille) et tourne en boucle toutes les 2 secondes.

bot.ts
// bot.ts: quotes both sides of the live BTC 5-minute round with post-only limit orders. npx tsx bot.ts
import { api, loadWallet, signIn, type Account, type Book, type Round } from './seeryx';
import { signOrder, signPair, tradingSession, type Funds, type Order } from './chain';
import { logValue } from './api-url.mjs';

const MARKET = '/markets/BTC/5m/current';
const SIZE = 10;       // shares per quote (at least minOrderShares of GET /api/fees)
const EDGE = 2;        // cents on each side of the midpoint
const MAX_HELD = 100;  // stop bidding a side once this many of its shares are held
const STOP_S = 15;     // leave the round this many seconds before its close (new orders stop 5 s before)
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
type Results = { results: { status: number; body: { order?: number; error?: string } }[] };

const wallet = await loadWallet();
await signIn(wallet);
const funds = await api<Funds>('/funds');
const { minOrderShares } = await api<{ minOrderShares: number }>('/fees');
// every order is signed by a trading session (one wallet signature now, then none)
const session = funds.onChainTrading ? await tradingSession(funds, wallet) : null;
const sign = (order: Order) => (session ? signOrder(funds, session, wallet.address, order) : order);
let quoted = '';
let seen = new Map<string, number>(); // shares held per side, to report fills

for (;;) {
  try {
    const round = await api<Round>(MARKET);
    if (round.end - Date.now() / 1000 < STOP_S) {
      // step aside before the close: what is still held pays out by itself when the round resolves
      await api(`/orders?slug=${round.slug}`, { method: 'DELETE' });
      await sleep((round.end + 2) * 1000 - Date.now());
      quoted = '';
      continue;
    }
    const [book, account] = await Promise.all([
      api<Book>(`/orderbook/${round.slug}?side=up`), // levels are [cents, shares], best first
      api<Account>('/account'),
    ]);
    const held = (side: string) =>
      account.positions.find((p) => p.slug === round.slug && p.side === side)?.shares ?? 0;
    for (const side of ['up', 'down']) {
      const change = held(side) - (seen.get(side) ?? 0);
      if (Math.abs(change) > 1e-9) console.log('filled', side, logValue(change));
    }
    seen = new Map([['up', held('up')], ['down', held('down')]]);

    // an Up and a Down share together always pay $1: merge pairs back into cash to free it for new quotes
    const pairs = Math.floor(Math.min(held('up'), held('down')));
    if (pairs >= minOrderShares) {
      const signed = session ? await signPair(funds, session, wallet.address, round.slug, true, pairs) : undefined;
      await api('/merge', { method: 'POST', body: { slug: round.slug, shares: pairs, signed } });
      continue;
    }

    const bid = book.bids[0]?.[0], ask = book.asks[0]?.[0];
    if (bid === undefined || ask === undefined) { await sleep(3000); continue; } // no two-sided book: no midpoint
    const mid = (bid + ask) / 2;
    // a Down bid at q is an Up ask at 100 - q: two bids quote both sides without holding shares
    const quotes = [
      { side: 'up' as const, limit: Math.floor(mid - EDGE) },
      { side: 'down' as const, limit: Math.floor(100 - mid - EDGE) },
    ].filter((q) => q.limit >= 1 && q.limit <= 99 && held(q.side) < MAX_HELD);
    const resting = account.orders.filter((o) => o.slug === round.slug).length;
    const key = `${round.slug} ${quotes.map((q) => q.side + q.limit).join(' ')}`;
    if (key !== quoted || resting !== quotes.length) { // the midpoint moved or a quote was filled: requote
      await api(`/orders?slug=${round.slug}`, { method: 'DELETE' });
      const base = { slug: round.slug, action: 'buy', type: 'limit', shares: SIZE, postOnly: true } as const;
      const orders: Order[] = quotes.map((q) => ({ ...base, ...q })); // post only: never takes, never pays the fee
      const body = { orders: await Promise.all(orders.map(sign)) };
      const { results } = await api<Results>('/orders/batch', { method: 'POST', body });
      results.forEach((r, i) => console.log(quotes[i].side, quotes[i].limit, logValue(r.body.order ?? r.body.error)));
      quoted = key;
    }
  } catch (error) {
    const e = error as { message: string; status?: number; retryAfter?: number };
    console.error(logValue(e.message));
    if (e.status === 401 || e.status === 403) await signIn(wallet); // the 7-day sign-in token expired
    if (e.retryAfter) await sleep(e.retryAfter * 1000); // 429 or 503: wait as told
  }
  await sleep(2000);
}

Exécutions et inventaire

  • Une exécution est une position. Un ordre d’achat Hausse exécuté vous laisse des parts Hausse : elles rapportent 1,00 $ chacune si Hausse l’emporte et rien dans le cas contraire. MAX_HELD plafonne la quantité d’un côté que le bot accumule.
  • Les paires sont des liquidités. Détenir les deux côtés vaut 1,00 $ assuré par paire : le bot fusionne donc aussitôt les paires pour les reconvertir en USDC (gratuitement) et place de nouveaux ordres avec ces liquidités.
  • Post-only garantit que chaque ordre du bot est maker : pas de frais taker, et la remise de 20 % sur chaque exécution. Un ordre qui croiserait le carnet est refusé (would_take) et simplement replacé au passage suivant.
  • Des exécutions plus rapides. Le bot interroge l’API à intervalles réguliers ; pour réagir plus vite, abonnez-vous à la manche et au compte via le WebSocket et réajustez vos prix à chaque message.
  • Adaptez-le : fixez les prix à partir d’un signal extérieur (la variation du prix du sous-jacent depuis l’ouverture de la manche) plutôt que du prix médian du carnet, décalez vos prix en fonction de ce que vous détenez, cotez les manches à venir et d’autres marchés.

Récompenses de liquidité

Les ordres à cours limité en attente près du prix médian rapportent des points de récompense sur les marchés où liquidityRewards de GET /api/markets vaut true (les marchés crypto de 5 minutes et de 15 minutes) et sur tous les marchés événementiels. Une fois par minute, chaque ordre est noté par rapport au prix médian du carnet Hausse de ce marché :

note=valeur×((3¢ − éloignement) / 3¢)²
RègleValeur
valeurLa taille de l’ordre en attente à son propre prix, en dollars (un achat Baisse à p compte comme un ordre de vente Hausse à 100 − p)
éloignementL’éloignement de l’ordre par rapport au prix médian entre le meilleur ordre d’achat et le meilleur ordre de vente Hausse ; à 3¢ ou plus, il ne rapporte rien
Points0,1 point par dollar de note et par heure, crédité minute par minute, par jour UTC
Les deux côtés+50 % sur un marché où vos ordres sont notés côté achat et côté vente
Pris en compteLes ordres restés au moins 60 secondes dans le carnet, d’une taille au moins égale à l’ordre minimum et à 5 $, dans une manche en cours avec un carnet à deux côtés
Plafond500 $ de valeur par marché et par côté, les ordres les plus proches d’abord

GET /api/rewards/liquidity renvoie vos points, le rythme auquel vos ordres en attente en gagnent actuellement et ces règles sous forme de nombres. Les points comptent pour les niveaux de Récompenses.

Avant de le lancer

  • Gardez wallet.json et session.json lisibles uniquement par vous ; la clé de session peut négocier vos liquidités dans ses limites.
  • Gérez les 429 en attendant Retry-After ; le bot le fait déjà.
  • Lisez GET /api/fees au démarrage : les taux et l’ordre minimum peuvent changer.