CCXT
TypeScript Examples

Order Router Custom Plan

Order Router Custom Plan — CCXT TypeScript code example.

// @NO_AUTO_TRANSPILE

// OrderRouter — execute a plan you built yourself.
//
// OrderRouter is two things. The other example (order-router.ts) uses the first:
// a client that asks the router service for the cheapest way to convert one
// asset into another. This example uses the second, which needs no router
// service and no apiKey at all:
//
//   execute() takes a PLAN, not a route, and never asks where the plan came
//   from. Your own strategy can supply its own list of trades and still get the
//   per-trade notional cap, halt-and-reconcile between hops, resting-order
//   cleanup, and an unwind plan for a run that stops half way.
//
// A step is one order on one venue. Required: exchangeId, symbol, side, amount,
// base, quote. Everything else carries predictions and may be omitted.
//
// Steps sharing a hopIndex are one hop: that is what parallel_within_hop runs
// concurrently, and what reconciliation chains together.
//
// THIS EXAMPLE PLACES REAL ORDERS. execute trades by default, exactly like
// createOrder does — there is no permission flag beside it. As written it builds
// its venues with no credentials, so an exchange will refuse the order; put your
// keys in and it will go through. Add a dryRun option to the execute call to
// rehearse instead. Read the hard safety rules in CLAUDE.md §5.5 before pointing
// this at real money.
//
// Usage:
//   npm run tsBuild && node js/examples/ts/order-router-custom-plan.js

import ccxt from '../../js/ccxt.js';

async function main () {
    //  no apiKey: nothing here talks to the router service
    const router = new ccxt.OrderRouter ({});

    const binance = new ccxt.binance ({});
    const kraken = new ccxt.kraken ({});
    const venues = { 'binance': binance, 'kraken': kraken };
    await Promise.all ([ binance.loadMarkets (), kraken.loadMarkets () ]);

    //  Identity. A live run REFUSES without one, because it is what makes a
    //  re-run safe: the identity is remembered in-process, so the same plan
    //  sent twice is refused before any venue is contacted.
    //
    //  Stable and tied to the INTENT — a strategy name plus the signal that
    //  triggered it. Date.now() here would be a fresh identity on every call,
    //  which turns the protection off while looking like it is on.
    const signalTime = '2026-09-06T12:00:00Z';
    const idempotencyKey = 'rebalance-' + signalTime;

    const plan = {
        'requestId': idempotencyKey,
        //  when these prices were true; execute reports planAgeMs against it
        'calculatedAt': binance.milliseconds (),
        'steps': [
            {
                'exchangeId': 'binance',
                'symbol': 'BTC/USDT',
                'side': 'buy',
                'amount': 0.0002,
                'base': 'BTC',
                'quote': 'USDT',
                'hopIndex': 0,
                'legIndex': 0,
                'expectedPrice': 64000,
            },
            {
                'exchangeId': 'kraken',
                'symbol': 'ETH/USDT',
                'side': 'buy',
                'amount': 0.004,
                'base': 'ETH',
                'quote': 'USDT',
                'hopIndex': 0,
                'legIndex': 1,
                'expectedPrice': 3200,
            },
        ],
    };

    //  Worth running on a hand-written plan: it checks every step against that
    //  venue's real market rules — minimum amount, minimum cost, precision —
    //  which is where a hand-picked amount usually goes wrong.
    const markets = { 'binance': binance.markets, 'kraken': kraken.markets };
    const violations = router.checkExecutionPlanSafety (plan, markets, {
        'maxNotionalUsd': 25,
        'usdRates': { 'USDT': 1 },
    });
    //  Only a BLOCKING violation should stop you. The advisory ones are worth
    //  reading and worth shipping past: a hand-built plan that does not set
    //  `fullyFillable` always draws a non-blocking `partial_fill`, so a guard
    //  on `violations.length` alone would refuse every plan on this page.
    let blocking = 0;
    for (let i = 0; i < violations.length; i++) {
        const label = violations[i]['blocking'] ? 'BLOCKING' : 'advisory';
        console.log ('  ', label, violations[i]['code'], violations[i]);
        if (violations[i]['blocking']) {
            blocking = blocking + 1;
        }
    }
    if (blocking > 0) {
        console.log ('plan rejected before any venue was contacted');
        return;
    }

    const report = await router.execute (plan, venues, {
        'strategy': 'parallel_within_hop',
        'usdRates': { 'USDT': 1 },
        'maxNotionalUsd': 25,
        //  alternative to plan.requestId; either satisfies the identity rule
        'idempotencyKey': idempotencyKey,
    });

    console.log ('strategy    ', report['strategy'], '(requested', report['requestedStrategy'] + ')');
    console.log ('planId      ', report['planId']);
    console.log ('planAgeMs   ', report['planAgeMs'], '  // -1 means unknown, not fresh');
    console.log ('wouldPlace  ', report['wouldPlaceOrders']);

    //  On a live run that halted, this is the reverse plan that sells each
    //  stranded residual back toward the asset you started in.
    //  const unwind = router.buildUnwindPlan (report);

    await Promise.all ([ binance.close (), kraken.close () ]);
}

main ();