NexusTrade # NexusTrade TypeScript SDK **Author trading strategies in typed TypeScript. Backtest them on the engine that runs them live.** [![npm](https://img.shields.io/npm/v/nexustrade.svg)](https://www.npmjs.com/package/nexustrade) [![Node](https://img.shields.io/node/v/nexustrade.svg)](https://www.npmjs.com/package/nexustrade) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Deps](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](package.json) [Quickstart](#quickstart) · [Authoring](#authoring-strategies) · [Polling](#jobs-run-on-the-engine--you-poll) · [Agents](#agent-runs) · [Lake SQL](#lake-sql) · [Auth](#authentication) · [Errors](#errors)
--- ```bash npm install nexustrade ``` **Zero runtime dependencies.** ESM and CommonJS builds ship together, with types. ## MCP server 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: ```bash claude mcp add --transport http nexustrade https://nexustrade.io/api/mcp ``` Cursor and other remote-capable clients use: ```json { "mcpServers": { "nexustrade": { "url": "https://nexustrade.io/api/mcp" } } } ``` For Claude Desktop and other stdio-only clients, use the established `mcp-remote` bridge—no clone or local NexusTrade server is required: ```json { "mcpServers": { "nexustrade": { "command": "npx", "args": [ "-y", "mcp-remote@latest", "https://nexustrade.io/api/mcp", "--transport", "http-only" ] } } } ``` 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](https://nexustrade.io/developers), the [utility tool reference](https://nexustrade.io/docs/api-reference/mcp-tools-utility), and the [Aurora tool reference](https://nexustrade.io/docs/api-reference/mcp-tools-aurora). 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. ## Quickstart ```ts import { NexusTradeClient, always, backtest, buy, portfolio, stockAsset, strategy, } from "nexustrade"; const client = new NexusTradeClient(); const book = portfolio("Example", [ strategy("Buy SPY", always(), buy(stockAsset("SPY"), 100)), ]); const operation = await client.createBacktest( backtest(book, { startDate: "2024-01-01", endDate: "2024-12-31" }), { idempotencyKey: "example-v1" } ); const result = await client.waitForBacktest(operation.id as string); console.log(result.result); ``` 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. ### Collateral at risk 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. ## Authoring strategies 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`: ```ts import * as nt from "nexustrade"; const book = nt.portfolio( "Momentum", [ nt.strategy( "Rotate into strength", nt.always(), nt.dynamicRebalance({ universe: nt.universe("SP500"), pipeline: [ nt.filter(nt.gt(nt.Price(nt.CANDIDATE), nt.SMA(nt.CANDIDATE, 200))), nt.selectTop(nt.RSI(nt.CANDIDATE, 14), 10), ], weightIndicator: nt.RSI(nt.CANDIDATE, 14), limit: 10, deploymentPercent: nt.Value(80), }) ), ], { initialValue: 100_000 } ); ``` Congressional-disclosure signals are native indicators too. House and Senate are combined unless you explicitly choose a chamber; amount metrics default to the disclosed lower bound and confirmed option disclosures are excluded by default: ```ts const pelosiPurchases = nt.PoliticalTrades( nt.CANDIDATE, "Nancy Pelosi", "BuyAmount", 90, "LowerBound", "Equity", ); ``` Use `""` as the filer to include all members. `BuyCount` counts canonical purchase events, while `BuyAmount` measures disclosed magnitude. Select `"Option"` only when you want option-disclosure activity associated with the underlying ticker. 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: ```ts nt.strategy("Buy SPY at my price", nt.always(), nt.buy(nt.stockAsset("SPY"), 10), { orderExecution: nt.limitOrder({ price: nt.unitPriceLimit(500), workingTime: nt.goodForDay(), }), }); nt.strategy("Sell the spread for $1.50 or better", nt.always(), optionAction, { orderExecution: nt.limitOrder({ price: nt.minimumNetCredit(1.5), workingTime: nt.goodForMinutes(30), }), }); ``` An option net limit can also follow a position the strategy holds. Pass an indicator instead of a number and the engine evaluates it when the strategy fires. If it has no positive value at that moment, no order is placed: ```ts nt.strategy("Sell the credit spread for the debit fill plus $0.50", whenDebitSpreadHeld, creditSpreadAction, { orderExecution: nt.limitOrder({ price: nt.minimumNetCredit( nt.Plus(nt.OptionSpreadEntryPrice("SPX", "call", "long", "vertical"), nt.Value(0.5)), ), workingTime: nt.goodForDay(), }), }); ``` `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. ### Option selection and execution limits Builders describe drafts. Typechecking does not prove that the engine can execute every requested rule; the API validates complete portfolios and their conditions. - Expiration selectors support a DTE window and `nearest`, `middle`, or `furthest`. `middle` selects the middle eligible expiration by index, not the expiration nearest a target such as 180 days. There is no monthly-only or quarterly-only expiration filter. - `rebalanceOption` accepts `renewBeforeDays` (integer 1–90) and `revisitDisclosedTargets`. These maintain modeled political-disclosure option targets. The strategy must contain a `NewPoliticalDisclosure` condition for instrument `Option` with exactly one member ID. These fields do not implement a general close-and-replace roll for a trend strategy. - For backtests, the job's `feeConfig.OptionSlippageFraction` sets quote-side fills: `0` uses the midpoint, `1` uses ask for buys and bid for sells, and the default is `0.5`. This job setting does not add a last-trade fallback or define live order execution. - Evaluation timing and order execution are separate. `OpenClose` schedules evaluations at session open and close; it does not itself carry a signal observed at close into an order at the next open. Explicit zero timing offsets and a `gt(MinutesAfterOpen(), Value(0))` condition can restrict evaluations to the calendar session close. `MinutesUntilClose` counts down to 16:00 Eastern, so it cannot identify an early session close. When authoring an approximation, describe the actual expiration policy, signal timing, fills, and replacement behavior. Backtest results describe that implementation, not the exact method it approximates.
What you can build — 170+ generated builders | Group | Examples | | ------------------- | -------------------------------------------------------------------------------------------- | | **Price & volume** | `Price` `OpeningPrice` `HighOfDay` `MinuteBarHigh` `MinuteBarLow` `HeikinAshi` `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` `OptionSpreadEntryPrice` `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.
## Jobs run on the engine — you poll `create*` enqueues work and returns immediately. It does **not** resolve when results exist. There are no webhooks today. ```mermaid sequenceDiagram participant You participant SDK participant Engine You->>SDK: createBacktest(book) SDK->>Engine: POST (enqueue) Engine-->>SDK: id, status=queued SDK-->>You: operation (returns immediately) loop waitForBacktest — backoff 2s→15s SDK->>Engine: GET /operations/{id} Engine-->>SDK: status update end SDK-->>You: result (when completed) Note over You,Engine: Poll timeout throws operation_timeout.
The job keeps running — call wait again with the same id. ``` Every job kind reports the same envelope, so one poller serves all of them: ```ts { id: "op_...", kind: "backtest", // backtest | optimization | walk_forward status: "queued", // queued | running | completed | failed | cancelled result: {...}, // present only once terminal error: { code, message, retryable }, } ``` ```ts const finished = await client.waitForBacktest(operation.id as string); ``` | 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: ```ts const study = await client.createWalkForward( nt.walkForward(book, { globalStartDate: "2022-01-01", globalEndDate: "2024-12-31", foldCount: 4, }), { idempotencyKey: "wf-v1" } ); await client.waitForWalkForward(study.id as string); ``` ## Deploying a portfolio Authoring and backtesting a book does not persist it. `save` writes it to your account; `deploy` starts running it. ```ts const book = nt.portfolio("Momentum", [ /* … */ ]); await book.save({ idempotencyKey: "momentum-v1" }); // persists; sets book.id const deployment = await book.deploy(); // starts paper trading await book.undeploy(); // stops 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. ```ts deployment.portfolioId; // the running portfolio deployment.deploymentType; // paper, unless you deployed an existing live one deployment.outcome; // created | reactivated ``` 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. ```ts await client.listPortfolios({ includePaper: true, includePositions: true }); await client.getPortfolio(portfolioId); ``` ### Editing a saved portfolio `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. ```ts import type { PortfolioEditOperation } from "nexustrade"; const operations: PortfolioEditOperation[] = [ { type: "rename", name: "AAPL Income" }, { type: "replaceStrategy", targetStrategyId: strategyId, // or targetStrategyName strategyObject: nt.strategy( "Buy AAPL", nt.always(), nt.buy(nt.stockAsset("AAPL"), 25, "percent of portfolio"), { orderExecution: nt.limitOrder({ price: nt.unitPriceLimit(150), workingTime: nt.goodForDay(), }), }, ), }, ]; await client.updatePortfolio(portfolioId, operations, { idempotencyKey: "aapl-limit-v1", }); ``` Prefer one `updatePortfolio` operation with a finished `portfolio` patch. It can combine `strategies`, `deployment` (frequency and timing), `name`, `policy.stockEligibility`, `dividendPolicy`, and alerts. Omitted fields retain their saved values. Draft starting capital and capabilities can change only before deployment. The existing strategy and rename operations remain supported. Every authored operation for the target in one call commits together; validation failure saves none of them. Reusing the same idempotency key replays the saved result. Deploy, undeploy, delete and automated approval remain separate account operations. **`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. Stock eligibility is part of what you author. Pass `policy: { stockEligibility }` to `portfolio(...)`, or call `setStockEligibility(...)` on a handle, to set market-cap bounds, an industry filter, `missingMarketCapBehavior`, or `shareClassBehavior`. Omitted fields take the defaults. A GOOG/GOOGL pairs book needs `ALL_CLASSES`, because the default keeps one share class per company: ```ts const pair = portfolio("GOOG/GOOGL pair", strategies, { policy: { stockEligibility: { shareClassBehavior: "ALL_CLASSES" } }, }); ``` Automated trading is never authored. Only the owner enables it, in Portfolio Settings, and a policy that names `automatedApproval` is refused before it is sent. Fetched handles include a typed, read-only `policy` snapshot; saving or deploying a copy of one carries its stock eligibility and nothing else. `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](#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 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. ```ts await client.listBrokerages(); // [{ brokerage: "Alpaca", connected: false, // connectUrl: "https://nexustrade.io/live-trading" }, ...] await client.connectBrokerage("Alpaca"); // logs the URL, waits until connected ``` `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: ```ts await client.listPortfolios({ includeLive: true, includePaper: false }); // NexusTradeApiError: brokerage_not_connected: No live portfolios, and no // brokerage is connected. Connect one at https://nexustrade.io/live-trading ``` ### Orders ```ts const result = await client.createOrders( portfolioId, [ { asset: { name: "SPY", type: "STOCK", symbol: "SPY" }, side: "BUY", quantity: 10, orderType: "MARKET", }, ], { idempotencyKey: "rebalance-2024-04-01" } ); // Dollar notional (stock/crypto only — options require contract quantity): await client.createOrders( portfolioId, [ { asset: { name: "AAPL", type: "STOCK", symbol: "AAPL" }, side: "BUY", amount: 500, orderType: "MARKET", }, ], { idempotencyKey: "buy-aapl-500" } ); ``` **Paper orders are accepted immediately. Live orders are staged for approval and are never sent to a broker by this call.** ```ts if (result.requiresApproval) { console.log("nothing has traded yet — approve at", result.approvalUrl); } ``` 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. Listing requires a `read` key; cancellation requires a `trade` key. Both require a registered account and are unavailable inside compute sandboxes. ```ts const page = await client.listOrders({ portfolioId, statuses: ["Accepted", "Pending User Approval"], page: 1, limit: 50, }); console.log(page.orders, page.totalPages); // Cancel only explicit IDs you selected from the listing. const cancellation = await client.cancelOrders(["ORDER_ID"], { portfolioId, idempotencyKey: "cancel-order-v1", }); console.log(cancellation.canceled, cancellation.rejected); ``` Listing defaults to `Accepted` and `Pending User Approval`. Request `Pending`, `Partially Filled`, `Filled`, or `Canceled` explicitly to inspect other states. Pages are 1-based, with a default limit of 50 and a maximum of 100. The response includes `orders`, `total`, `returned`, `page`, `limit`, `totalPages`, `truncated`, and `ignoredStatuses`; unknown statuses are reported there when valid statuses remain, and a wholly invalid filter raises an error. Set `includeRebalanceOrders: true` for an additional page of pending rebalance actions. Their IDs are separate from order IDs and cannot be passed to cancellation. Cancellation accepts at most 50 IDs and returns `canceled` and `rejected` arrays. Each success includes `orderId`, `mode` (`db`, `broker`, or `already_canceled`), and `canceledOrderIds`, including sibling legs canceled together. Each rejection includes `orderId` and `reason`. Inspect both arrays: HTTP success does not mean every order was canceled. The optional portfolio ID guards against canceling an order from another portfolio. Reuse the same idempotency key and identical payload when retrying; an already canceled order returns `already_canceled`. The same cancellation rules as MCP/REST apply: `Accepted` and `Pending User Approval` orders can be canceled; `Pending`, `Partially Filled`, and `Filled` orders are rejected. Rebalance children must be managed through their parent action. Live orders with a broker ID invoke broker cancellation. Creation still stages live orders for human approval. ## Your own data 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`. ```ts const series = await client.createCustomIndicator( { name: "WSB NVDA Mentions", scope: "asset", description: "Daily r/wallstreetbets mentions", pointKind: "observation", points: [ { timestamp: "2024-04-01", value: 152, ticker: "NVDA" }, { timestamp: "2024-04-02", value: 90, ticker: "NVDA" }, ], }, { idempotencyKey: "wsb-mentions-v1" } ); const busy = nt.gt( nt.CustomIndicator(nt.stockAsset("NVDA"), String(series.customIndicatorId)), 100 ); const book = nt.portfolio("Attention", [ nt.strategy("Buy the buzz", busy, nt.buy(nt.stockAsset("NVDA"), 25)), ]); ``` `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: ```ts await client.appendCustomIndicatorPoints( String(series.customIndicatorId), [{ timestamp: "2024-04-03", value: 118, ticker: "NVDA" }], { idempotencyKey: "wsb-mentions-2024-04-03" } ); ``` 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. ## Agent runs 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: ```mermaid sequenceDiagram participant You participant Run as AgentRun participant Engine You->>Run: createAgent(prompt) Run->>Engine: POST /agents Engine-->>Run: run id loop for await (const event of run) Run->>Engine: GET events (cursor) Engine-->>Run: new events alt event.needsApproval Run-->>You: plan or action awaiting approval You->>Run: approve() or reject() Run->>Engine: POST approval else event.needsInput Run-->>You: awaiting user input You->>Run: say("...") Run->>Engine: POST message else Run-->>You: event.text end end Run-->>You: terminal Note over You,Engine: Without approve/say, the run stalls and bills.
Reattach later with attachAgent(run.id). ``` ```ts const run = await client.createAgent("Find momentum names in the S&P 500", { idempotencyKey: "momentum-scan-v1", costCeilingUsd: 20, }); for await (const event of run) { console.log(event.text); if (event.needsApproval) await run.approve(); if (event.needsInput) await run.say("Focus on tech"); } ``` ## Lake SQL 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. ```mermaid flowchart LR A[createLakeQuery] --> B[waitForLakeQuery] B --> C[getLakeQueryManifest] C --> D[downloadLakeQueryPart] D --> E[Stream Parquet within your memory budget] ``` ```ts const query = await client.createLakeQuery( { query: "SELECT ticker, date, closingPrice FROM lake.daily_ohlc WHERE ticker = ?", params: ["AAPL"], limits: { maxRows: 10_000 }, }, { idempotencyKey: "aapl-daily-v1" } ); const finished = await client.waitForLakeQuery(query.id as string); const manifest = await client.getLakeQueryManifest(finished.id as string); ``` ## Natural language 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. ```ts const screen = await client.createNlScreen( "technology stocks with a market cap over 100 billion and a PE under 30" ); const done = await client.waitForNlScreen(screen.id as string); const result = done.result as Record; console.log(result.rows); console.log(result.sql); // always check the SQL — it is model-generated ``` `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. ## Managed watchlist universes `nt.updateWatchlist({watchlistKey, memberId, instrument, amountBasis, chamber, output})` maintains a visible managed list from `PoliticalRemainingPurchases`. Its strategy has no condition and places no orders. `nt.watchlistUniverse(watchlistKey)` selects that producer's committed membership for a separate `DynamicRebalance` in the same portfolio. Each key has one producer and may have several consumers. Other action types cannot consume this universe. ```ts const producer = nt.updateWatchlist({ watchlistKey: "pelosi-holdings", memberId: "P000197", instrument: "Equity", amountBasis: "Midpoint", chamber: "All", output: {name: "Pelosi disclosed holdings", purpose: "Follow public holdings", alertSettings: {onChange: true, inApp: true, email: true, briefCadence: "weekly"}}, }); const consumerUniverse = nt.watchlistUniverse("pelosi-holdings"); ``` `output` supplies initial name, purpose and alerts. Subsequent user renames and settings remain authoritative. Live and paper consumers wait for durable list publication; backtests keep membership local to the run and never edit the user's visible list. Discovery and data loads remain full, so this does not establish a performance saving. Omitted amount basis uses `LowerBound`. `updateInstitutionalWatchlist` follows an exact manager's latest publicly available complete equity book. `updateInsiderWatchlist` selects qualifying positive purchases by first public availability, with optional owner, issuer, and role scope. Both use the same publication and consumer contract: ```ts const manager = nt.updateInstitutionalWatchlist({ watchlistKey: "berkshire-equities", managerCik: "1067983", }); const insider = nt.updateInsiderWatchlist({ watchlistKey: "insider-purchases", windowDays: 90, role: "OfficerOrDirector", }); const screen = nt.updateScreenerWatchlist({ watchlistKey: "berkshire-top-20", refreshMinutes: 1440, columns: [nt.screenColumn("price", nt.screenPrice()), nt.screenColumn("holding", nt.screenDisclosure(manager.source))], filter: nt.screenAll(nt.screenCompare("price", "Gte", 5), nt.screenCompare("holding", "Gt", 0)), selection: nt.screenTop("holding", 20), }); const screenUniverse = nt.watchlistUniverse("berkshire-top-20"); ``` The screen evaluates on its scheduled cadence and reuses accepted membership between refreshes. Missing facts remain unknown. `screenAny` composes OR rules; `screenPercentile` ranks the filtered candidates with known rank values. `screenBarVolume` and `screenBarDollarVolume` require a completed historical bar; live quotes do not supply those metrics in this release. Price and disclosure metrics support live screens. Failed coverage or publication blocks new allocation while consumers retain their exit behavior. Screen execution uses in-memory DuckDB; no local database volume or LLM call is required for refreshes. ## Complete method reference 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 | | `listOrders(options)` | List owned orders by portfolio/status, with pagination | | `cancelOrders(orderIds, { idempotencyKey, portfolioId })` | Cancel explicit IDs with a result for each | **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 })` | Atomically save a finished authored portfolio patch | | `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 | | `setStockEligibility(eligibility)` | Set the stock eligibility it will send | ## Authentication 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: ```ts const guest = new NexusTradeClient(); await guest.listPortfolios(); const token = guest.exportWorkspaceSession(); const resumed = new NexusTradeClient({ workspaceSession: token }); ``` 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](https://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. ```ts const client = new NexusTradeClient({ apiKey: "sk-...", baseUrl: "https://nexustrade.io/api/v1", }); // or set NEXUSTRADE_API_KEY / NEXUSTRADE_API_BASE_URL and: const fromEnv = new NexusTradeClient(); ``` 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: ```bash # .env NEXUSTRADE_API_KEY=sk-... NEXUSTRADE_API_BASE_URL=https://nexustrade.io/api/v1 ``` 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 with > `401 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. ## Idempotency 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. ```ts await client.createBacktest(handle, { idempotencyKey: "momentum-2024-v1" }); ``` ## Errors ```ts import { NexusTradeApiError } from "nexustrade"; try { await client.createBacktest(handle, { idempotencyKey: "run-1" }); } catch (error) { if ( error instanceof NexusTradeApiError && error.code === "rate_limit_exceeded" ) { // back off } throw error; } ``` | 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. ## Timeouts `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. ## Scope 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. ## Requirements 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. ## Using this SDK with a coding agent See **[AGENTS.md](AGENTS.md)** — the conventions, invariants, and recipes an agent needs to write correct NexusTrade strategies on the first pass. ## License MIT