The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the NexusTrade Financial MCP listing page.
Author trading strategies in typed TypeScript. Backtest them on the engine that runs them live.
Quickstart · Authoring · Polling · Agents · Lake SQL · Auth · Errors
Zero runtime dependencies. ESM and CommonJS builds ship together, with types.
NexusTrade also exposes the platform as a hosted, remote Model Context Protocol server. Modern MCP clients connect directly to the production Streamable HTTP endpoint and discover NexusTrade OAuth automatically:
Cursor and other remote-capable clients use:
For Claude Desktop and other stdio-only clients, use the established
mcp-remote bridge—no clone or local NexusTrade server is required:
The live server exposes more than 120 tools across market research, portfolio construction, backtesting, optimization, walk-forward validation, managed compute, Aurora agents, paper trading, and controlled brokerage operations. Its creator-marketplace tools cover the full strategy adoption path:
search_creators discovers public creators and their marketplace portfolios.subscribe_portfolio validates a monetized listing and returns a safe
checkout preview; the user completes payment in NexusTrade, never through the
MCP tool.fork_shared_portfolio creates a one-time editable copy of a marketplace
strategy in a new or existing portfolio.copy_trade_shared continuously mirrors an accessible strategy into a paper
or live portfolio at an explicit allocation.See the developer guide, the utility tool reference, and the Aurora tool reference.
Research and historical results are not investment advice and do not guarantee future performance. Keep paper and live modes explicit. Tools that can affect portfolios, schedules, or brokerage orders remain subject to the authenticated account's NexusTrade permissions and approval controls.
Backtest operations may include warnings: string[] immediately after
submission and again in the terminal result. Treat them as material caveats;
they do not change a successful operation into a failure.
A terminal operation's result.statistics answers how much capital the run had
on the line, not only what it returned. Two fields carry it, typed as
BacktestCollateralStatistics:
| Field | Meaning |
|---|---|
peakReservedCollateral | Largest collateral locked at any tick, in account currency |
medianReservedCollateral | Median across the ticks that held at least one position |
Both are optional and may be null, and that absence is a real answer: a
backtest run before the engine reported collateral has no value, which is not
the same as a book that locked nothing. Display "not recorded" rather than $0
— a zero here reads as "this strategy risks nothing", the opposite of what an
unpopulated field means. Do not substitute the portfolio's value either: a book
risking a few thousand dollars would be reported as risking all of it.
Never reconstruct either number from cash - buyingPower. Buying power is
clamped at both ends and carries an open credit-spread premium term, so the
inversion breaks precisely on the heavily collateralised books this measures.
Every builder is generated from the same indicator specification the NexusTrade engine runs, so a book is valid by construction rather than by convention.
TypeScript cannot overload comparison operators, so indicators compose through
gt / gte / lt / lte / eq / neq and and / or:
Order execution belongs to the strategy. Omit it for the backward-compatible Market default, use a fixed unit price for Buy/Sell, or set an option strategy's maximum net debit / minimum net credit:
currentLimit() creates a quote-relative Limit for dynamic rebalance strategies.
It keeps the no-worse-than-current-quote protection, but it is not a resting
price target. Live option strategies must choose an explicit Limit policy.
| Group | Examples |
|---|---|
| Price & volume | Price OpeningPrice HighOfDay VWAP Volume GapPercentage |
| Technicals | SMA EMA RSI BollingerBand AverageTrueRange CrossAbove |
| Position state | PositionValue PositionPercentChange PositionMaxDrawdown |
| Portfolio state | PortfolioValue BuyingPower MaxDrawdown InitialValue |
| Fundamentals | Fundamental Economic DaysUntilEarnings IsIndexMember IsIndustry |
| Options | OptionDaysToExpiration OptionCollateral OptionUnrealizedPnL openOption closeOption |
| Actions | buy sell alert dynamicRebalance rebalanceOption |
| Selection | filter selectTop selectPercentile universe |
| Logic | always atLeast atMost exactly fewerThan multi and or sequence |
| Anchored levels | IndicatorAtEntry LastOrderPrice IndicatorAtMinutesAfterOpen IndicatorWindowAgo |
Every builder is fully typed — your editor completes the whole surface.
create* enqueues work and returns immediately. It does not resolve when
results exist. There are no webhooks today.
Every job kind reports the same envelope, so one poller serves all of them:
| Option | Default | Meaning |
|---|---|---|
timeoutSeconds | 900 | Give up waiting (the job keeps running) |
pollIntervalSeconds | 2 | First interval; backs off 1.5× |
maxPollIntervalSeconds | 15 | Interval ceiling |
throwOnFailure | true | Throw on failed/cancelled instead of returning |
A timeout throws operation_timeout and does not cancel the job — call the
waiter again with the same id rather than resubmitting.
Batches. createBacktests submits many in one request and returns one
operation each; waitForBacktests(operations) waits on all of them. Prefer it
over a loop: one request, one idempotency key, one rate-limit slot.
Optimization and walk-forward follow the identical shape:
Authoring and backtesting a book does not persist it. save writes it to your
account; deploy starts running it.
save and deploy produce different ids, and the distinction matters.
save persists a draft and sets book.id to it. deploy mints the real
paper portfolio and returns its own portfolioId — deploying creates a
portfolio rather than converting the draft into one, so the two ids coexist.
Hold on to deployment.portfolioId for anything that reads live state;
book.id addresses the draft.
Handle methods accept an optional transport; omitted, they resolve one from
the environment. The same operations exist on the client — client.deploy(id),
client.undeploy(id) — when you have an id rather than a handle.
updatePortfolio applies deterministic edits with no LLM in the path. The
operations array is a typed union, so the compiler knows which fields each
edit needs.
The five edits are rename, addStrategies, removeStrategies,
replaceStrategy, and replaceStrategies. Deploy, undeploy, delete,
scheduling, and trading-policy operations are not reachable on this route.
replaceStrategies replaces the whole set, so a strategy left out of the
array is deleted. Carry unchanged strategies through verbatim, including the
orderExecution each already has, or a working Limit silently reverts to
Market. removeStrategies takes strategy ids from a fetched portfolio; removal
by name is rejected.
Fetched portfolio handles include a typed, read-only policy snapshot. Trading
policy changes are intentionally unavailable through the SDK; edit them in
Portfolio Settings. PortfolioHandle.toJSON() omits the snapshot so a fetched
portfolio cannot accidentally submit policy changes through an authoring call.
listPortfolios filters with includePaper, includeLive, includeInactive,
includeChatPortfolios, search, limit, and page. includePositions
defaults off when search is set.
A portfolio you create here is always paper, and minting a live one still happens in the web app. Orders and brokerage status are reachable from here; see Live trading.
But deploy can start live trading. Given the id of a portfolio that is
already deployed, it reactivates that portfolio as whatever it already is — so
client.deploy(id) on a paused live portfolio resumes live trading against the connected
brokerage, and includeLive: true above will hand you such an id. Check deployment.deploymentType before
treating a deploy as simulated.
Live trading needs a brokerage linked to your account. Linking is an OAuth redirect, so an API key cannot complete it — a human opens the URL.
connectBrokerage waits by default only when stdout is a TTY. In CI, cron,
or run_compute it rejects with brokerage_not_connected immediately, with the
URL in the message, rather than stalling for five minutes in front of nobody.
Pass { wait: true } or { wait: false } to force either.
A live-only listing that comes back empty rejects with the same error rather than an empty array, since an empty array says nothing about why:
Paper orders are accepted immediately. Live orders are staged for approval and are never sent to a broker by this call.
There is no argument, scope, or flag that submits a live order without approval. The brokerage boundary refuses an unapproved live order regardless of what any caller asks for, so this is a property of the system rather than a promise made by this method. At most 50 orders per request.
A custom data source is a time series you own — sentiment counts, a proprietary
factor, anything the platform does not already carry. Create one, then reference
it from a strategy with CustomIndicator.
scope is "global" (one series) or "asset" (one series per ticker, so every
point needs a ticker). It cannot be changed after creation.
Declare pointKind whenever the time semantics are known: observation for
point-in-time samples, period_aggregate plus aggregatePeriod (1d, 1w,
1mo, or 1q) for closed-period values, and disclosed for values with an
explicit publication time on every row. The SDK applies this contract before
both inline and large-upload writes. A same-day date-only observation becomes
an explicit same-day UTC instant instead of shifting to the next calendar day.
Size is not a constraint. points is unlimited. A batch that fits the
request goes with it; a larger one is uploaded to storage and validated before
the call resolves. Either way the returned indicator reflects what actually
landed, and an upload that fails validation rejects rather than reporting
success.
Growing a series. Append to the same id every run:
Creating a fresh series per run splits the history into fragments no strategy can read. Re-sending an identical batch is safe — the duplicate is not written twice.
| Call | Purpose |
|---|---|
createCustomIndicator(spec, { idempotencyKey }) | Create, optionally seeded |
appendCustomIndicatorPoints(id, points, { idempotencyKey }) | Add points |
replaceCustomIndicatorPoints(id, points, { idempotencyKey }) | Replace points, retain id |
archiveCustomIndicator(id) / restoreCustomIndicator(id) | Reversible lifecycle |
listCustomIndicators() / getCustomIndicator(id) | Discover ids and coverage |
Points accept timestamp, value, ticker, assetType, and availableAt
— camelCase or snake_case, with Date objects allowed. Set availableAt when a
value became knowable later than it is dated: an earnings figure stamped to
quarter-end but published weeks after. An unrecognized field throws rather than
being silently dropped.
To hand over a file you already have on disk, createCustomIndicatorUpload /
completeCustomIndicatorUpload / waitForCustomIndicatorUpload expose the
three steps directly. CSV, JSON, and JSONL up to 100 MB.
Every other job is fire-and-poll. Agents are not — three states
(pending_plan_approval, pending_action_approval, awaiting_user_input)
cannot advance without you. Iterate the run and answer when it blocks:
Read-only SQL over the NexusTrade market-data lake, against the server-resolved
lake.* catalog. Results are durable Parquet parts rather than an implicitly
materialized in-memory array.
Describe the screen instead of writing the SQL. The server generates it,
validates it against the same lake.* catalog the engine reads, executes it,
and hands back both the rows and the statement.
returnQuery defaults to true because the SQL is the audit trail: without it
the rows are a number you cannot re-derive. It is returned on failure whatever
you pass, since a rejected query is the most useful thing to read.
Branch on result.outcome, not on status alone:
outcome | Meaning |
|---|---|
ROWS | Matches found |
EMPTY | Every filter ran and nothing cleared them all — an answer |
CLARIFICATION | The question was ambiguous; result.clarification asks |
GENERATION_FAILED | The retry budget was spent — the only case worth retrying |
This method spends LLM credits. The structured lake API below does not.
Use the manifest plus downloadLakeQueryPart to stream results within your own
memory budget. NexusTrade picks a compatible backing engine for the referenced
tables; your SQL does not change when it does.
The Python SDK additionally ships
nt.lake.sql(...), a DuckDB/pandas convenience layer over these same endpoints.
Every public method on NexusTradeClient. A test in this package fails if one
is missing here, so this list cannot drift from the code.
Live trading and orders
| Method | Purpose |
|---|---|
listBrokerages() | Every connectable brokerage and whether it is linked |
getBrokerage(brokerage) | Whether one brokerage is linked |
connectBrokerage(brokerage, { wait }) | Log the connect URL and wait for the link |
createOrders(portfolioId, orders, { idempotencyKey }) | Stage orders; live ones need approval |
Portfolios
| Method | Purpose |
|---|---|
createPortfolio(book, { idempotencyKey }) | Persist a portfolio definition |
listPortfolios(options) | List portfolios, with filters and pagination |
getPortfolio(portfolioId) | Read one portfolio |
updatePortfolio(id, operations, { idempotencyKey }) | Rename or edit strategies deterministically |
forkPublicPortfolio(sharedId, { idempotencyKey }) | Fork a public portfolio into the workspace |
deploy(portfolioId, { frequency }) | Start paper trading it |
undeploy(portfolioId) | Stop it |
Backtests
| Method | Purpose |
|---|---|
createBacktest(handle, { idempotencyKey }) | Submit one backtest |
createBacktests(handles, { idempotencyKey }) | Submit many in one request |
getBacktest(backtestId) | Read the operation |
waitForBacktest(backtestId, options) | Block until terminal |
waitForBacktests(operations, options) | Block on a whole batch |
Optimization and walk-forward
| Method | Purpose |
|---|---|
createOptimization(handle, { idempotencyKey }) | Submit an optimization |
getOptimization(optimizationId) | Read the operation |
waitForOptimization(optimizationId, options) | Block until terminal |
createSystematicSweep(handle, { idempotencyKey }) | Submit an explicit-gene sweep |
getSystematicSweep(optimizationId) | Read the sweep operation |
waitForSystematicSweep(optimizationId, options) | Block until terminal |
createWalkForward(handle, { idempotencyKey }) | Submit a walk-forward study |
getWalkForward(studyId) | Read the operation |
waitForWalkForward(studyId, options) | Block until terminal |
Custom data sources
| Method | Purpose |
|---|---|
createCustomIndicator(spec, { idempotencyKey }) | Create a series, optionally seeded |
listCustomIndicators(options) | List owned series |
getCustomIndicator(id) | Read one, with its point count and range |
appendCustomIndicatorPoints(id, points, { idempotencyKey }) | Add points |
replaceCustomIndicatorPoints(id, points, { idempotencyKey, allowShrink }) | Replace the complete series while retaining its id |
archiveCustomIndicator(id, { confirm }) | Soft-archive a series |
restoreCustomIndicator(id) | Restore an archived series |
createCustomIndicatorUpload(id, options) | Open an upload slot (CSV/JSON/JSONL) |
completeCustomIndicatorUpload(id, jobId) | Start validating uploaded bytes |
getCustomIndicatorUpload(id, jobId) | Read the upload operation |
waitForCustomIndicatorUpload(id, jobId, options) | Block until validated |
Agent runs
| Method | Purpose |
|---|---|
createAgent(prompt, { idempotencyKey }) | Start a run |
getAgent(agentId) | Read its status |
attachAgent(agentId, { cursor }) | Reattach to a run already in flight |
Lake SQL
| Method | Purpose |
|---|---|
createLakeQuery(request, { idempotencyKey }) | Submit read-only SQL |
getLakeQuery(queryId) | Read the operation |
waitForLakeQuery(queryId, options) | Block until terminal |
cancelLakeQuery(queryId) | Cancel an owned query |
createLakeAsk(question) | Ask the lake in plain language |
getLakeAsk(askId) | Read the operation |
waitForLakeAsk(askId, options) | Block until terminal |
cancelLakeAsk(askId) | Cancel an owned ask |
getLakeQueryManifest(queryId) | Schema, checksums, and part metadata |
downloadLakeQueryPart(queryId, part, options) | Download one Parquet part |
getLakeCatalog() | List queryable tables |
describeLakeTable(table) | Columns and types for one table |
Natural language
| Method | Purpose |
|---|---|
createNlScreen(question, { returnQuery }) | Screen stocks from a plain-language question |
getNlScreen(screenId) | Read the operation |
waitForNlScreen(screenId, options) | Block until terminal |
cancelNlScreen(screenId) | Cancel an owned screen |
Client construction
| Method | Purpose |
|---|---|
new NexusTradeClient({ apiKey, baseUrl }) | Explicit credentials |
new NexusTradeClient() | Lazy anonymous workspace with strict limits |
NexusTradeClient.fromEnvironment() | Read them from the environment or .env |
exportWorkspaceSession() | Export an anonymous workspace for later use |
importWorkspaceSession(token) | Resume an existing anonymous workspace |
PortfolioHandle — returned by the portfolio(...) builder and by
getPortfolio / listPortfolios.
| Method | Purpose |
|---|---|
save({ idempotencyKey }) | Persist it as a draft, setting .id |
backtest({ startDate, endDate, idempotencyKey }) | Backtest it, preferring the saved id |
deploy({ frequency }) | Mint the real paper portfolio (new id) |
undeploy() | Deactivate its deployment |
An API key is optional. With no key, the first API operation lazily creates a real unregistered NexusTrade workspace and applies stricter request, backtest, and AI limits. Anonymous workspaces can create, edit, and fork portfolios, launch backtests, and use the programmatic agent/chat surface. Every optimization operation—including genetic and systematic sweep launches, result reads, reruns, promotion, and out-of-sample workflows—requires a registered API key. Export the workspace's opaque token if the work must survive a new process:
An expired explicit workspace token raises
NexusTradeWorkspaceSessionExpiredError; the SDK never creates a replacement
workspace that would make saved work appear deleted.
Registered users can create a key at
nexustrade.io/developers (Profile → API
Keys). Keys start with sk- and are shown once. When both credentials are
provided, registered Authorization takes precedence and the workspace header
is not sent.
Both variables are also read from a .env file at or above the current
directory, so a local project works with no exports, no dotenv dependency, and
no --env-file flag:
The real environment always wins — a .env value is used only when the variable
is absent, so a stale file can never override what you exported. Nothing is
written back to process.env. Opt out with NEXUSTRADE_DISABLE_DOTENV=1.
| Scope | Grants |
|---|---|
read | Portfolio, backtest, genetic, sweep, and walk-forward reads |
write | Portfolio create/edit/fork, backtests, genetic/sweep, and walk-forward launches |
lake | Lake catalog, query lifecycle, manifests, result parts |
A key missing the scope gets 403 insufficient_scope.
OAuth is not accepted here. NexusTrade's OAuth flow serves the MCP server. These endpoints take
sk-API keys only; a bearer JWT is rejected with401 invalid_token.
Transport hardening. HTTPS is required (except loopback). The client refuses
cross-origin redirects, so the credential cannot be replayed to another host, and
refuses to follow a redirect on any non-GET request, so a redirect can never
re-submit a paid job. The key is held in a #private field and never appears in
a stringified client.
Every mutation takes a key. Reusing the same key with the same request returns the original resource instead of launching a second paid job — so a retry after a network failure is free.
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_token | Missing, malformed, or expired key (or an OAuth JWT) |
| 403 | insufficient_scope | Key lacks read, write, or lake |
| 400 | invalid_request, invalid_portfolio | Malformed input |
| 400 | invalid_idempotency_key | Must match [A-Za-z0-9._:-]{1,160} |
| 409 | idempotency_conflict | Key reused with a different payload |
| 409 | idempotency_in_progress | Same key, first call still running. Re-poll, do not resubmit |
| 404 | not_found, operation_not_found | Unknown or not yours |
| 429 | rate_limit_exceeded | Back off and retry |
status is 0 when no HTTP status describes the failure: transport_error
(never reached the API), unsafe_redirect, or an invalid_response envelope
check on an otherwise-successful reply.
new HttpTransport({ timeoutSeconds }) (default 30) is a total wall-clock
deadline for one request. Neither it nor the poll timeout bounds how long a
job takes.
Portfolio drafting, backtesting, optimization, walk-forward studies, and
read-only SQL over the market-data lake, versioned under /api/v1/nexustrade.
The full surface requires a registered API key; anonymous workspaces are limited
to portfolio authoring/forking, backtests, and programmatic agent/chat calls.
The screener and creating a live deployment remain outside this surface.
Orders are reachable, but a live order is only ever staged for human approval —
never submitted. deploy and undeploy act on whatever an existing id already
is, live included.
Node 18+ (uses the global fetch). Contributing: the test suite runs TypeScript
directly via node --test, which needs Node 22.6+ for type stripping. The
published dist/ is plain JavaScript and has no such requirement.
See AGENTS.md — the conventions, invariants, and recipes an agent needs to write correct NexusTrade strategies on the first pass.
MIT