@koniverse/trader
v0.5.0
Published
Self-contained Layer-1 direct-client SDK for crypto-exchange auto-trading bots. Direct port over ccxt; no platform entanglement.
Readme
@koniverse/trader
A self-contained npm SDK for writing crypto-exchange auto-trading bots. Imported into your own
process, holds one ccxt.pro client, and gives you a direct client.* surface — watchOHLCV,
placeMarket, bracket, setSltp, sweepBrackets, balance, … — without bringing the
Koni Meta Trader platform along for the ride.
- Install:
npm i @koniverse/trader— that is the whole install.ccxtis a dependency of this package and is re-exported from it, so you never declare it yourself. - Use: load credentials from
.env, build a client, drive the methods yourself. - Reach: any venue
ccxtcan construct — 105 ids across theproand REST registries. Two of them are certified; the rest connect and read all the same. - Phase 1 (TS-only): one direct client, no backtest, no marketplace. The same surface, identically named, will ship on PyPi in a later phase.
Quickstart — a defineBot trading one closed bar
1. Install
npm i @koniverse/traderOne package. ccxt comes with it as this package's only dependency, and is re-exported — so
client.exchange, ccxt's error classes and ccxt.pro are all reachable without a second install,
and they reach the same copy the client was built from. See
Holding a specific ccxt release if you need one this package
has not floored yet.
2. .env
# A CEX: Binance USDⓈ-M demo keys
KONI_BOT_API_KEY=…
KONI_BOT_SECRET=…
# OKX / KuCoin / Bitget also want the passphrase (ccxt calls it `password`; we do not)
# KONI_BOT_PASSPHRASE=…
# A DEX: hyperliquid signs with a wallet key instead
# KONI_BOT_WALLET_ADDRESS=0x…
# KONI_BOT_PRIVATE_KEY=0x…
# Your bot's own parameters live here too. Declare them once in bot.manifest.json
# and derive the config from that declaration — see (k) below.
BOT_SYMBOL=BTC/USDT:USDT
BOT_EMA_FAST=9Which variables a venue needs is not a list this package maintains — ask it. The answer is a
Result, because an id ccxt cannot construct is a refusal rather than an empty array:
import { requiredCredentials, isRefusal } from '@koniverse/trader';
const need = requiredCredentials('okx');
if (!isRefusal(need)) need.value; // ['KONI_BOT_API_KEY','KONI_BOT_SECRET','KONI_BOT_PASSPHRASE']
requiredCredentials('hyperliquid'); // → ['KONI_BOT_PRIVATE_KEY','KONI_BOT_WALLET_ADDRESS']
requiredCredentials('nosuchvenue'); // → Refusal { code: 'unsupported', … }mode is live, testnet or demo, and the SDK verifies the switch took: a venue that
cannot serve the mode you asked for refuses, rather than handing back a production client. Not
every venue has every environment — hyperliquid has testnet but no demo, binanceusdm the
reverse.
3. bot.ts
import { createClientFromEnv, defineBot, isRefusal } from '@koniverse/trader';
const built = await createClientFromEnv('binanceusdm', { mode: 'demo' });
if (isRefusal(built)) {
console.error(`connect refused: ${built.code} (${built.scope})`);
process.exit(1);
}
const client = built.value;
let bars = 0;
const bot = defineBot(client, {
symbols: ['BTC/USDT:USDT'],
timeframes: ['1m'],
sweep: false, // this loop only reads; do not reap bracket legs
onBoot: (_event, client) => {
bot.log('boot', { venue: client.id, mode: client.mode, registry: client.registry });
},
onBar: ({ bar, symbol }) => {
bot.log('bar', { symbol, close: bar.close });
if (++bars >= 1) void bot.stop(); // one closed bar is enough for a smoke
},
});
const summary = await bot.run(); // run() closes the client on the way out
if (isRefusal(summary)) {
console.error(`run refused: ${summary.code} (${summary.scope})`);
process.exit(1);
}
console.log('done', summary.value.counts);Run with tsx bot.ts (or node after tsc). The first closed bar arrives, you log it, and the
loop shuts down cleanly.
What the SDK supports
Every call returns Result<T> — either { ok: true, value } or a Refusal. Nothing here throws
for a venue condition.
(a) Connection & lifecycle
| Call | What it does |
|---|---|
| createClientFromEnv(exchangeId, { mode }) | The single ccxt construction site. Any id ccxt knows — pro registry first, plain REST second. mode is verified by its effect, not by whether a method exists. |
| requiredCredentials(exchangeId) | The KONI_BOT_* variables this venue needs, derived from ccxt's own map in our spelling. |
| loadCredentials() / verifyCredentials() | Env loader, and a signed probe that proves account control (hyperliquid: the submitted wallet must be the agent key's master). |
| client.id / client.mode / client.registry | registry: 'rest' means the venue publishes no ccxt.pro class, so every watch* refuses feed_unavailable — distinguishable from "this feed is down". |
| client.exchange | The raw ccxt slice, for anything this surface does not cover. Typed as ccxt's own Exchange — reach the class through the re-export (import { ccxt } from '@koniverse/trader'), never a second install. |
| ccxt | ccxt itself, re-exported. ccxt.Exchange, ccxt.pro, ccxt.RateLimitExceeded, every exchange class — the SDK's own copy, so client.exchange instanceof ccxt.Exchange is true and your rate-limit config applies to the requests that actually go out. |
| client.close() | Idempotent. Stops every registered stream, then tears down sockets and timers. |
(b) Market data — REST
| Call | Notes |
|---|---|
| ohlcv(symbol, tf, opts?) | Closed candles, oldest-first. The forming candle is excluded. |
| ticker(symbol) / tickers(symbols?) | bid/ask are null where the venue publishes none. A bulk request over the venue's measured symbol cap refuses rather than truncating into a partial answer. |
| orderbook(symbol, { limit }) | limit is a promise this SDK keeps: passed to the venue and the result truncated, because venues measurably disagree about honouring it. |
| trades(symbol, opts?) | The public tape. order and fee are null on every row. |
| markPrice(symbol) | Carries the instant it was observed — a naked number cannot say how stale it is. Derived from the venue's bulk read where the singular under-reports. |
| fundingRate(symbol) / fundingRates(symbols?) / fundingRateHistory(symbol, opts?) | Contract markets only; a spot symbol refuses before the wire naming its market kind. A history row carrying no rate is dropped, not zero-filled. |
| market(symbol) / markets() | Tick, step, min quantity, min notional — as decimal strings and numbers. |
(c) Market data & account — realtime
Nine watch* feeds over one driver and one reconnect policy. All synchronous: a feed the venue
does not publish is refused before any socket opens, so you branch before committing to a
for await.
| Public | Private |
|---|---|
| watchOHLCV(symbol, tf, opts?) (closed bars) | watchOrders(symbol?, opts?) |
| watchTicker(symbol, opts?) | watchMyTrades(symbol?, opts?) |
| watchTrades(symbol, opts?) | watchPositions(symbols?, opts?) |
| watchOrderBook(symbol, opts?) | watchBalance(opts?) |
| watchMarkPrice(symbol, opts?) | |
The eight Stream<T> feeds deliver frames and gaps on one iterator:
const feed = client.watchOrders('BTC/USDT:USDT');
if (isRefusal(feed)) return;
for await (const ev of feed.value) {
if (ev.kind === 'gap') {
// The socket dropped and came back. The venue's own error, a monotonic counter, how many
// reconnects it took and how long the feed was down. The SDK re-fetches NOTHING.
console.warn('gap', ev.gap, ev.downMs, ev.cleanClose, ev.reason);
continue;
}
ev.data; // an Order — the SAME object `fetchOrder` returns, mapped eagerly inside the tick
}- A gap is reported, not handled. What to re-read is your call: the SDK runs no REST resync, buffers nothing pending one, and reconciles nothing. A clean close still emits a gap — suppressing it because the close was polite would be the SDK deciding which interruptions you are allowed to know about.
- No replica of venue state. An order going
filled → open → filledarrives three times, in order. A duplicate after a resubscribe is delivered. There is no keyed collection anywhere behind these feeds, and a build-failing structural test keeps it that way. - Two concurrent streams over the same
(method, symbol)are refused — ccxt.pro's delta cursor is consuming, so two pumps would each see a fraction of the frames and neither would know.
close() also clears the timers the venue scheduled on itself. binanceusdm's user-data stream
arms a 20-minute listen-key keep-alive through ccxt's delay(), which discards the handle — so a
bounded script that opened a private feed used to finish its work and then sit on a live event loop
forever, looking exactly like a hung bot. close() owns those handles now, and a script that is
done exits on its own.
(d) Account, positions & orders — reads
| Call | Notes |
|---|---|
| balance(currency?) | Defaults to the venue's settle currency. A currency the account holds nothing in refuses; it is never reported as 0. |
| positions(symbols?) | side from the sign of the size, never the venue's bucket. qty >= 0, flat rows dropped. |
| openOrders(symbol?) | Resting orders including conditionals — the regular ∪ conditional union. An unreadable conditional sweep refuses rather than handing back the regular set as if it were everything. |
| openOrdersByClass(symbol?) | The same enumeration with the classes kept apart and conditionalCovered stated — the degraded view, asked for by name. |
| protection(symbol) | The guards resting on a symbol, plus coverage: 'observed' \| 'unknown'. |
| closedOrders(symbol?, opts?) / myTrades(symbol?, opts?) | Pagination passes through to the venue. |
| fetchOrder(id, symbol) / fetchOrderByClientId(coid, symbol) | Reads from whichever endpoint holds it, retrying the conditional one only on not_found. The per-venue client-id param spelling is measured, never inferred. |
(e) Cashflow — the money that moves outside the fills
ledger(opts?) · tradingFees() · tradingFee(symbol) · fundingHistory(symbol?, opts?) —
behind four independent capability gates, so a venue missing one still serves the others.
Two facts the SDK states rather than smoothing over:
tradingFees()(plural) on binanceusdm comes from ccxt's own static fee table indexed by the account'sfeeTier— one pair stamped onto every linear symbol.tradingFee(symbol)reads the account's real commission rate. Same shape, two provenances.ledger()is a superset offundingHistory()on binanceusdm — the same income endpoint, one of them filtered — so summing the two double-counts funding. The SDK does not de-duplicate: that is an accounting decision and it is yours.
(f) Order placement & management — writes
| Call | Notes |
|---|---|
| placeMarket(symbol, side, qty, opts?) | opts.price supplies the minNotional reference on a venue with no market primitive. |
| placeLimit(symbol, side, qty, price, opts?) | The full admission predicate runs before the wire. |
| placeTrigger(symbol, side, qty, { triggerPrice, when, type, price? }, opts?) | A conditional that OPENS — the breakout shape. when: 'above' \| 'below' is required: the SDK reads no price to infer it, because that inference races the market and inverts on a short. Carries neither reduceOnly nor a close flag, and an explicit opts.reduceOnly is refused, not dropped. |
| placeTrailing(symbol, side, qty, { callbackPercent \| callbackAmount, activationPrice? }, opts?) | The venue's own trail, or a refusal naming what it does publish. Nothing is emulated: a cancel-and-replace loop has different failure modes and stops when your process does. |
| bracket(symbol, side, qty, { stop?, take? }, opts?) | Enter with protection attached — native, emulated split-leg, or refused before the wire. A Refusal means the entry did not happen. Check reapedBy: 'sweeper' means you owe sweepBrackets(). |
| setSltp(symbol, request, opts?) | Arm SL/TP on an open position, reporting the leg it replaced. |
| sweepBrackets(symbol?) | Cancel the emulated legs that no longer guard anything. Explicit by design — this SDK never cancels an order on a schedule you did not choose. |
| amend(orderId, symbol, changes) | result.mode says which of two things the venue did: 'amended' kept the id and all its state; 'recreated' destroyed and rebuilt it, so the SDK re-sent reduceOnly, the leg class and the kb- bracket lineage for you. A field this venue's amend cannot carry is refused before the wire. |
| cancel(orderId, symbol) / cancelAll(symbol?) | cancel answers a CancelOutcome: a conditional's cancel ack is a bare confirmation naming no side, so order is null and the cancel still succeeded. cancelAll refuses on an unreadable conditional set — "cancelled everything" while a stop-loss is still resting is the one lie this SDK will not tell. (A free cancelAccepted(api, …) → Result<true> export exists for callers that only want the fact.) |
| closePosition(symbol, { side?, hedged? }) | side names which position; hedged states the account's mode — two facts, not one. An un-named close on a two-sided symbol refuses: closing the wrong one doubles the exposure. Sized from that position's freshly-read quantity, through the same admission predicate a placement uses. |
(g) Derivatives account configuration
positionMode() / setPositionMode(mode) · marginMode(symbol) / setMarginMode(symbol, mode) ·
leverage(symbol) / setLeverage(symbol, n).
Every setter returns one shape:
interface ConfigChange<T> {
requested: T;
applied: boolean; // did the venue CHANGE something? false = it was already there (a success)
verified: boolean; // did the SDK read it back and find it equal?
mode: T; // what is in force, as far as the SDK can state
}A venue that cannot report a mode is refused, not assumed. A change blocked by an open position
or a resting order answers state_conflict carrying the venue's own sentence — a well-formed
request the account's state forbids, which is a different remedy from bad_request.
(h) Precision & safety
roundPrice(symbol, px, intent, side?) · roundAmount(symbol, qty) · checkOrder(symbol, qty, px).
Synchronous, so they sit inside a bar handler. Quantities round down, always. checkOrder is
the same admission predicate the placements run — a sub-minimum order is refused, never nudged
up. Arithmetic under the boundary is scaled-integer, not float.
(i) The optional run-loop — defineBot
defineBot(client, definition) → { run, stop, bars, log, every, after, clearTimer }, with
onBoot / onBar / onOrder / onFill / onTimer / onError / onShutdown, declared
requires resolved to a refuse-to-boot, warmup applied to history rather than dispatched, one
FIFO with a fixed dispatch order, timers, structured JSON logging, and SIGINT/SIGTERM shutdown.
Nothing in (a)–(h) imports it. The imperative client is complete on its own; the loop depends on the client, never the reverse. It is also frozen: it stays because it shipped, and it takes no new features.
(j) The in-doubt write
A refusal carrying reachedVenue: 'unknown' means the request may or may not have landed, and a
blind retry can double-submit. There is no resolveInDoubt wrapper, because you set the
clientOrderId and the answer is six lines of your own source:
const placed = await client.placeLimit(symbol, 'buy', qty, px, { clientOrderId: coid });
if (isRefusal(placed) && placed.reachedVenue === 'unknown') {
const found = await client.fetchOrderByClientId(coid, symbol);
if (!isRefusal(found)) { /* it LANDED — do not resend */ }
else if (found.code === 'not_found') { /* absent — a retry is safe */ }
else { /* the lookup itself failed — this is evidence of neither */ }
}Restart is the same picture with no magic: openOrders() + positions() + myTrades(since).
What counts as "clean" is your strategy's call — grid, market-making and delta-neutral bots hold
stopless positions on purpose, so the SDK does not have an opinion about it.
(k) bot.manifest.json — your parameters as data
Declare your parameter set once, in a JSON file beside your source, and derive the config from it. The point is not the file format: it is that the declaration and the consumption are the same artifact, so a deployment platform can render a form from the same thing your bot parses — and the two cannot disagree.
{
"manifestVersion": "1",
"bot": { "id": "ema-crossover", "displayName": "EMA crossover", "sdkRange": "^0.4.0" },
"parameters": [
{ "name": "BOT_SYMBOL", "type": "string", "label": "Symbol", "required": true },
{ "name": "BOT_EMA_FAST", "type": "integer", "label": "Fast EMA", "required": false,
"default": 9, "min": 1 },
{ "name": "BOT_EMA_SLOW", "type": "integer", "label": "Slow EMA", "required": true, "min": 2,
"description": "must be greater than BOT_EMA_FAST" },
{ "name": "BOT_NOTIONAL_USDT", "type": "number", "label": "Notional", "required": true,
"exclusiveMin": 0 },
{ "name": "BOT_TIMEFRAME", "type": "enum", "label": "Timeframe", "required": true,
"enum": ["1m", "5m", "15m"] },
{ "name": "KONI_BOT_API_KEY", "type": "string", "label": "API key", "required": true,
"secret": true }
]
}import { readFileSync } from 'node:fs';
import { defineConfig, isRefusal } from '@koniverse/trader';
const manifest = JSON.parse(readFileSync(new URL('../bot.manifest.json', import.meta.url), 'utf8'));
const config = defineConfig(manifest, process.env);
if (isRefusal(config)) {
console.error(config.message);
process.exit(1); // YOUR boundary decides what is fatal — the SDK returns, it never exits
}
config.value.BOT_EMA_FAST; // 9, frozen, and a number rather than a string| Call | Notes |
|---|---|
| validateManifest(value) | Result<BotManifest> over anything JSON.parse produced. Refuses an unknown field, a default alongside required: true, a default breaking its own type / bound / enum, min above max, a duplicate name, and a name outside ^[A-Z][A-Z0-9_]{0,63}$ or reserved by the host runtime (PATH, LD_PRELOAD, SERVICE_*). |
| defineConfig(manifest, env) | Validates the manifest first — an author error says so, rather than reading as a bad value someone typed — then resolves env[name] → default → refuse-if-required, coerces, bounds-checks, and returns a frozen record. |
Four things worth knowing before you write one:
- A configuration failure names every offending parameter at once. Fixing a deploy should cost one iteration, not one per mistake.
booleanaccepts exactly"true"and"false", case-sensitively."1","yes","on"and"False"are all refused, and the refusal says so. Under a truthiness-shaped coercion"False"istrue, and a bot that inverts a flag over a capital letter fails in the worst way available. For the same reasonintegerrefuses"3.5"instead of truncating it to3.- Cross-field relations stay yours. The format is flat and per-field on purpose:
BOT_EMA_SLOW > BOT_EMA_FASTis not expressible, so keep that check in your bot after the call and write the rule into the parameter'sdescription— that is what a form shows the operator. secret: trueis mandatory on aKONI_BOT_*name, and the name must be one the SDK actually computes (KONI_BOT_API_KEY, notKONI_BOT_APIKEY). The flag is routing, not decoration: it is what tells a deployment platform to hold the value for one call instead of storing it. A secret's value never appears in a refusal — only its name.
There is no pattern in v1 (regex dialects disagree across the languages this SDK plans to ship in,
and the platform runs it in a browser over operator input), no type inference from the manifest, and
no loader — you read the file, because a helper that throws when it is missing is a dependency you
did not ask for.
Certified venues
Certification is a confidence tier, not an entry gate. Every venue ccxt can construct
connects, reads, and writes whatever exchange.has declares. A CERTIFIED venue is one somebody put
through the twelve-dimension procedure live and committed the evidence for; that is what unlocks
the writes needing a curated fact (conditional order classes, bracket shape, trigger basis) and
nothing else. A venue absent from the list loses the label and nothing more.
import { CERTIFIED_VENUES, venueTruth, CCXT_VERSION } from '@koniverse/trader';
CERTIFIED_VENUES; // ['binanceusdm', 'hyperliquid']
venueTruth('okx') === null; // true: connects and reads, no curated writes
CCXT_VERSION; // the ccxt version actually resolved in your treeThe evidence ships with the package under certification/<exchangeId>.json, one file per venue,
carrying the mode, symbol, ccxt version and one line of observation per dimension. Both records
were measured 2026-08-04 against ccxt 4.5.70 and re-run on the same day, so they share a
vintage: a list that certifies its second venue against code its first was never re-measured under
is a list nobody should trust.
That 4.5.70 is also this package's ccxt floor, which is not a coincidence — the evidence and
the shipped dependency move together. ^4.5.70 still admits later 4.x releases, so
CCXT_VERSION can legitimately read higher than the certified one; when it does, the certification
records describe an older release than the code you are running, and the difference is yours to
judge. Pin with an override (below) if that matters for your venue.
The two are deliberately unalike — an api-key pair against a wallet key, a market primitive against
none, a venue-managed close flag against reduceOnly as the only channel, a 36-character client id
against 128-bit hex, a separate conditional endpoint against trigger orders on the regular set,
readable margin and leverage against neither, a bulk cancel against none.
binanceusdm measures twelve of twelve; hyperliquid measures eleven with margin_and_leverage
explicitly unsupported — kept distinct from unknown, because recording an unmeasured thing as
absent claims a fact about the venue that is really a fact about us.
Refusal codes
Fifteen, closed, and every one of them is something this SDK can actually emit:
unsupported · requires_auth · risk_refused · symbol_not_owned · rounding_impossible ·
shutting_down · feed_unavailable · bad_request · auth_error · rate_limited ·
venue_unavailable · timeout · not_found · state_conflict · unknown
reachedVenue is three-state from day one — true, false, 'unknown' — because a transport
timeout is genuinely the in-doubt case.
Reference bot
A complete, runnable skeleton is at examples/reference-bot.ts. It
runs one bounded write cycle against any venue, in a non-live mode:
preflight the credentials → connect and report the registry → warn if the venue is not certified →
read the baseline through openOrdersByClass (coverage stated) → ask the venue for positionMode
/ marginMode / leverage / tradingFee → open watchOrders beside the loop → read market
context (markPrice, fundingRate, orderbook) → size at the venue's own minimum → place a
MARKET bracket (long, 3% SL, 3% TP) → resolve an in-doubt write via fetchOrderByClientId →
confirm the position and then protection() → probe amend on the take-profit leg → close
side-scoped, poll until flat, sweepBrackets, and assert zero residue from fresh reads.
# From the sdk/typescript/trader directory. Demo/testnet keys — never live ones.
export KONI_BOT_API_KEY=… KONI_BOT_SECRET=…
pnpm smoke:e2e # binanceusdm demo, BTC/USDT:USDT
tsx examples/reference-bot.ts --venue=hyperliquid --mode=testnet
tsx examples/reference-bot.ts 'ETH/USDT:USDT'
tsx examples/reference-bot.ts --hedged # override the venue's own positionMode read--mode accepts demo and testnet only; live is refused, because this file opens a real
position and a typo that trades real money is not a class of accident worth leaving reachable.
It imports from the SDK barrel (@koniverse/trader) — the exact public package root, never
../src — refuses to start on a non-empty or partially-covered baseline, and runs its cleanup
while the client is still open, because bot.run() closes it on the way out.
Restart caveat: process memory does not survive a restart. This bot rebuilds its baseline from a fresh read and stops without touching anything if that baseline is non-empty. Full reconcile is the author's, by design — see (j) above.
The model
- The author owns the loop.
defineBotis optional — the imperative client (client.watchOHLCV,client.placeMarket,client.bracket, …) is complete on its own. - Refusals are values, not exceptions. A call that cannot go through returns a
Refusal{ ok: false, code, scope, reachedVenue, message }.throwis reserved for programmer error.isRefusal(x)narrows it;client.*returnsResult<T> = { ok: true, value: T } | Refusal. - One ccxt construction site.
createClientFromEnvis the only placeccxtis wired up. Everything else takes a structural slice — so a fake can stand in for tests, and a future Python port can re-derive the same surface without sharing code. ccxtis a dependency, and it is re-exported. One install, one copy, oneExchangeclass — which is what makesinstanceofanswer correctly and your rate-limit settings apply to the requests that go out.ccxtstill ships weekly breaking releases; the^4.5.70floor is set at the release the venue certifications were measured against, and an override is how you hold a different one.- The SDK holds no state on your behalf. No restart memory, no local files, no database, no world-view rebuilt after a crash. It feeds data from the venue and sends orders to it; the bot's logic, state and lifecycle are yours.
- Curated venue truth is an OVERRIDE, never a gate.
exchange.hasis the default capability surface; a descriptor entry may only correct a flag a live measurement caught being wrong. No file butvenue-truth.tsmay branch on an exchange id, and a test fails the build if one does.
Compatibility
Since the published 0.1.0, two signatures changed. Both are one-property migrations:
| Call | 0.1.0 | Now | Why |
|---|---|---|---|
| setLeverage(symbol, n) | Result<true> | Result<ConfigChange<number>> | r.value → r.value.mode. The applied / verified split is what makes a write-vs-confirm difference visible to a bot. |
| cancel(orderId, symbol) | Result<Order> | Result<CancelOutcome> | r.value → r.value.order, plus a null case that previously arrived as a refusal you had to special-case anyway. A conditional's cancel ack names no side, so there is no row to return — and the old shape therefore refused a cancel the venue had confirmed. |
Order.role also reads differently: a conditional that opens exposure now reads 'entry', and
only one that closes reads 'stop' / 'take'. That is a correction rather than a break — no
0.1.0 call could create a conditional entry, so the previous reading was reachable only through
client.exchange or the venue's own UI, and it was wrong in both. It fixes protection(), which
counts exactly the 'stop' / 'take' rows and would otherwise present a breakout entry as the
thing guarding the position.
reconcile() never shipped in a published version and cannot break a consumer.
ccxt moved from a peer to a dependency
Through 0.2.0, ccxt was a peerDependency at >=4.4 <5 and you installed it yourself. It is
now a plain dependency at ^4.5.70 and is re-exported from this package. (0.3.0 shipped that
dependency at ^4.5.59; 0.4.0 raises the floor to the release the certifications are now
measured at.)
If you already declare ccxt in a compatible range, nothing breaks. Your manager sees two
requirements for one package and resolves a single copy that satisfies both — the same
deduplication that happens for any shared transitive dependency. You can leave the row where it is;
dropping it just removes a decision you no longer have to make. The reason to drop it is that a row
pinned outside ^4.5.70 gives you two copies, and two copies is the failure mode this change
exists to remove: instanceof answers false about objects that are instances, each copy carries
its own rate-limiter, and Exchange stops being assignable to Exchange.
Holding a specific ccxt release
^4.5.70 floors the version at the release the certifications were measured against and admits
later 4.x. If you need a different one — a fix newer than the floor, or an older release you have
qualified — use your manager's override rather than a direct dependency. An override applies to
every copy in the tree, which is what keeps it at one; a direct dependency at a conflicting range
adds a second.
// npm / bun — package.json
{ "overrides": { "ccxt": "4.5.72" } }
// pnpm — package.json
{ "pnpm": { "overrides": { "ccxt": "4.5.72" } } }
// yarn (berry and classic) — package.json
{ "resolutions": { "ccxt": "4.5.72" } }Below the ^4.5.70 floor is untested territory: the venue certifications were measured at that
release and the package's own suite runs against it. Above it is ordinary ccxt upgrade risk — read
their changelog, and remember CCXT_VERSION tells you what actually resolved.
Shape of the package
src/
├── index.ts barrel (re-exports everything below)
├── domain.ts Order, Position, Account, MarketMeta, ConfigChange, Protection, …
├── scalars.ts Side, PosSide, Ms, Timeframe, Dec, PositionMode, MarginMode, …
├── refusal.ts isRefusal, refuse, ok, Result<T>
├── decimal.ts the scaled-integer core
├── precision.ts scaled-integer arithmetic under the precision boundary
├── venue-truth.ts per-venue curated facts — an OVERRIDE, never a gate
├── venue-certification.ts the twelve dimensions, the tier, and CERTIFIED_VENUES
├── capabilities.ts exchange.has gate + the refusal-taxonomy
├── ccxt-errors.ts taxonomy shared by the read + write paths
├── ccxt-slice.ts the structural slice of ccxt this package depends on
├── credentials.ts loadCredentials, requiredCredentials
├── connect.ts createClientFromEnv — the single construction site, and the
│ `ccxt` re-export (a pass-through from the one importer, not a second site)
├── normalize.ts ccxt rows → domain objects, one mapper per type
├── market-meta.ts tick / step / minQty / minNotional, per symbol
├── market-data/{rest,stream}.ts
├── reconnect.ts one reconnect policy behind every watch*
├── stream-driver.ts one pump behind every watch*, gaps reported and never resynced
├── private-stream.ts watchOrders / watchMyTrades / watchPositions / watchBalance
├── account.ts balance / positions / closedOrders / myTrades / protection
├── open-orders.ts the regular ∪ conditional union, and the degraded by-class view
├── account-config.ts positionMode / marginMode / leverage, read and set
├── cashflow.ts ledger / tradingFees / tradingFee / fundingHistory — four gates,
│ no de-duplication
├── orders.ts placeMarket / placeLimit / cancel / cancelAll / closePosition
├── order-lookup.ts fetchOrder / fetchOrderByClientId — the in-doubt answer
├── amend.ts amend — 'amended' or 'recreated', stated per venue
├── bracket.ts bracket / setSltp / sweepBrackets (three modes, none silent)
├── trigger.ts placeTrigger (a conditional that OPENS) / placeTrailing
├── manifest.ts validateManifest / defineConfig — a parameter set declared once,
│ and the frozen config derived from that declaration
└── bot.ts, bot-account.ts, bot-timers.ts, bot-requires.ts
the optional defineBot run-loopLicense
MIT — see LICENSE.
