# Trading

> Place BUY and SELL orders for your master account or any sub-account. MARKET orders use quoteId; LIMIT/STOP orders rest without quotes. Covers order types, time-in-force, history, cancellation, ledger, and portfolio.

Place BUY and SELL orders on behalf of your master account or any sub-account. **MARKET** orders use a
two-step quote -> trade flow in both sandbox and production: fetch a quote, then place the order with
that `quoteId` within 60 seconds. Resting `LIMIT`, `STOP`, and `STOP_LIMIT` orders are placed without
a quoteId because your request supplies the trigger price; they rest as `WORKING` until activated.
Sandbox market orders fill instantly; live production market orders submitted during exchange
hours target `PENDING → FILLED / REJECTED` within 5 minutes. v1 may emit legacy `COMPLETED` as an alias of `FILLED`. Always use `Idempotency-Key` on trade
calls.

Production and sandbox enforce the same single-use quote rule. Sandbox claims the quote atomically
only after funds or holdings validation, so an `INSUFFICIENT_FUNDS` rejection does not consume an
otherwise valid quote. Reusing a successfully claimed quote returns `409 STALE_QUOTE`.

<FlowDiagram
  direction="horizontal"
  nodes={[
    { label: 'Quote', sublabel: 'GET /quote/{symbol}', tone: 'accent' },
    { label: 'PENDING', sublabel: 'POST /trade — escrowed' },
    { label: 'FILLED', sublabel: 'order.filled', tone: 'success' },
  ]}
/>

The full state machine — including LIMIT/STOP, cancellation, and expiry — is in
[Order & Money Lifecycles](/partners/docs/lifecycles).

## Get a quote

<MethodTag m="GET" /> `/quote/{symbol}`

<TryEndpoint id="getQuote" />

**Step 1 of every MARKET trade (sandbox and production).** Returns the fee breakdown -- gross value, base fee
(0.75%), optional partner markup, total cost (BUY) or estimated proceeds (SELL), sufficient-funds check
-- and a `quoteId` that the subsequent MARKET `/trade` call **requires**. Resting LIMIT/STOP orders skip
this endpoint. Quotes are single-use and expire after **60 seconds** (`quoteExpiresAt`); a stale or reused quoteId returns `409 STALE_QUOTE`, and a
quoteId whose symbol/side/quantity does not match the order returns `409 QUOTE_ORDER_MISMATCH`. Quotes
are indicative, not binding: settlement executes at the market price, protected by a deviation band
(default +/-10% of the quoted price).

<ParamTable fields={[
  { name: 'type',         type: 'string', required: true,  desc: 'BUY or SELL.' },
  { name: 'quantity',     type: 'number', required: false, desc: 'Number of whole shares (positive integer) — the order placed against this quote must match it. For fractional investing use cashValue instead. Mutually exclusive with cashValue.' },
  { name: 'cashValue',    type: 'number', required: false, desc: 'USD amount for cash-mode (fractional) investing. Mutually exclusive with quantity. The trade must use the SAME cashValue.' },
  { name: 'subAccountId', type: 'string', required: false, desc: 'Sub-account ID. Omit to quote against the master wallet. The quoteId is bound to this account context, symbol, side, and sizing mode.' },
]} />

<CodeTabs
  curl={`curl "https://mystocks.africa/api/v1/partner/quote/SCOM.KE?type=BUY&quantity=500&subAccountId=usr_abc123" \
  -H "Authorization: Bearer pk_live_<key>"`}
  node={`const res = await fetch(
  "https://mystocks.africa/api/v1/partner/quote/SCOM.KE?type=BUY&quantity=500&subAccountId=usr_abc123",
  { headers: { Authorization: "Bearer pk_live_<key>" } }
);
const quote = await res.json();`}
  python={`import requests

res = requests.get(
    "https://mystocks.africa/api/v1/partner/quote/SCOM.KE",
    params={"type": "BUY", "quantity": 500, "subAccountId": "usr_abc123"},
    headers={"Authorization": "Bearer pk_live_<key>"},
)
quote = res.json()`}
  sdk={`const quote = await client.trading.getQuote("SCOM.KE", {
  type: "BUY",
  quantity: 500,
  subAccountId: "usr_abc123",
});`}
/>

```json
{ "quoteId": "qt_9f2c81d4b7a3", "quoteExpiresAt": "2026-07-07T09:46:00Z", "quoteTtlSeconds": 60, "symbol": "SCOM.KE", "exchange": "NSE", "currency": "KES", "type": "BUY", "quantity": 500, "localPrice": 16.5, "usdPrice": 0.127306, "gross": 63.65, "baseFee": 0.48, "partnerMarkupFee": 0.0, "fee": 0.48, "totalCost": 64.13, "sufficientFunds": true, "feeRate": 0.75, "note": "Pass quoteId to POST /trade within 60 seconds. No order has been placed." }
```

## Mobile quote refresh

The 60-second TTL protects quote-to-fill price integrity and is not extended for backgrounded mobile
sessions. Treat `quoteExpiresAt` as the source of truth: display a countdown, refresh automatically when
10 seconds remain, and request a new quote whenever the app resumes. Never submit an expired `quoteId`.
Refreshing a quote does not create an order and is safe without an idempotency key; only the subsequent
trade mutation requires one. Preserve the user's symbol, side, and sizing mode, but require confirmation
again if the refreshed total cost, proceeds, fee, or quantity changes.

## Place a trade

<MethodTag m="POST" /> `/trade` _(master)_ · <MethodTag m="POST" /> `/users/{userId}/trade` _(sub-account)_

<TryEndpoint id="createUserTrade" />

Place a BUY or SELL order. BUY escrows the cost (gross + fee) from the wallet immediately; SELL checks
that the sub-account holds the required units. The symbol accepts exchange-qualified (`SCOM.KE`) or
bare ticker (`SCOM`).

<ParamTable fields={[
  { name: 'symbol',        type: 'string',  required: true,  desc: 'Exchange-qualified ticker or unambiguous bare ticker.' },
  { name: 'quantity',      type: 'number',  required: false, desc: 'Share-sized order quantity. Use either quantity or cashValue, never both. Whole-share markets require integers; fractional-enabled markets allow decimals.' },
  { name: 'cashValue',     type: 'number',  required: false, desc: 'USD notional for cash-mode fractional investing. Use either cashValue or quantity, never both. MARKET trades must match the quoted cashValue.' },
  { name: 'quoteId',       type: 'string',  required: false, desc: 'Required only for MARKET orders, including when orderType is omitted. Must come from GET /quote/{symbol}; single-use, expires after 60s. Do not send for LIMIT/STOP/STOP_LIMIT.' },
  { name: 'orderType',     type: 'string',  required: false, desc: 'MARKET (default) | LIMIT | STOP | STOP_LIMIT. Resting order types do not use quoteId and return status WORKING.' },
  { name: 'limitPrice',    type: 'number',  required: false, desc: 'Local-currency limit price. Required for LIMIT and STOP_LIMIT.' },
  { name: 'stopPrice',     type: 'number',  required: false, desc: 'Local-currency trigger price. Required for STOP and STOP_LIMIT.' },
  { name: 'clientOrderId', type: 'string',  required: false, desc: 'Your internal order reference (<=80 chars). Unique per partner -- a safe retry/dedupe handle alongside Idempotency-Key.' },
  { name: 'timeInForce',   type: 'string',  required: false, desc: 'GTC (default), DAY (auto-cancelled after 24h), or GTD (auto-cancelled at expiresAt). Expiry refunds BUY escrow / releases SELL reservations and fires order.cancelled. IOC is not supported.' },
  { name: 'expiresAt',     type: 'string',  required: false, desc: 'ISO-8601. Required when timeInForce is GTD.' },
  { name: 'stopLoss',      type: 'number',  required: false, desc: 'Auto-sell price floor (USD). Optional.' },
  { name: 'takeProfit',    type: 'number',  required: false, desc: 'Auto-sell price ceiling (USD). Optional.' },
  { name: 'takeProfit',    type: 'number',  required: false, desc: 'Auto-sell price ceiling (USD). Optional.' },
]} />

<CodeTabs
  curl={`# Step 1 - quote (quoteId expires in 60s)
curl "https://mystocks.africa/api/v1/partner/quote/SCOM.KE?type=BUY&quantity=1000&subAccountId=usr_abc123" \
  -H "Authorization: Bearer pk_live_<key>"

# Step 2 - place the order with that quoteId
curl -X POST "https://mystocks.africa/api/v1/partner/users/usr_abc123/trade" \
  -H "Authorization: Bearer pk_live_<key>" \
  -H "Idempotency-Key: trade_user42_001" \
  -H "Content-Type: application/json" \
  -d '{"symbol":"SCOM.KE","type":"BUY","quantity":1000,"quoteId":"qt_9f2c81d4b7a3","clientOrderId":"my-ord-10001"}'`}
  node={`const order = await fetch(
  "https://mystocks.africa/api/v1/partner/users/usr_abc123/trade",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer pk_live_<key>",
      "Idempotency-Key": "trade_user42_001",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      symbol: "SCOM.KE",
      type: "BUY",
      quantity: 1000,
      quoteId: "qt_9f2c81d4b7a3",
      clientOrderId: "my-ord-10001",
    }),
  },
).then((res) => res.json());`}
  python={`import requests

order = requests.post(
    "https://mystocks.africa/api/v1/partner/users/usr_abc123/trade",
    headers={
        "Authorization": "Bearer pk_live_<key>",
        "Idempotency-Key": "trade_user42_001",
        "Content-Type": "application/json",
    },
    json={
        "symbol": "SCOM.KE",
        "type": "BUY",
        "quantity": 1000,
        "quoteId": "qt_9f2c81d4b7a3",
        "clientOrderId": "my-ord-10001",
    },
).json()`}
  sdk={`const order = await client.subAccounts.trade(
  "usr_abc123",
  {
    symbol: "SCOM.KE",
    type: "BUY",
    quantity: 1000,
    quoteId: "qt_9f2c81d4b7a3",
    clientOrderId: "my-ord-10001",
  },
  { idempotencyKey: "trade_user42_001" },
);`}
/>

```json
{ "status": "PENDING", "orderId": "ord_abc123", "quoteId": "qt_9f2c81d4b7a3", "clientOrderId": "my-ord-10001", "timeInForce": "GTC", "subAccountId": "usr_abc123", "type": "BUY", "symbol": "SCOM.KE", "quantity": 1000, "priceAtOrder": 16.5, "usdPriceAtOrder": 0.0126, "gross": 12.6, "fee": 0.09, "totalCost": 12.69, "note": "Order is pending. Funds reserved from sub-account wallet." }
```

Production returns `PENDING` with the reserved price as `priceAtOrder` (local) and `usdPriceAtOrder`.
In **sandbox** the same call resolves instantly to `FILLED` and instead carries `usdPrice`,
`localPrice`, `currency`, and the post-trade `newSubBalance`.

<Callout type="warn">
  **Sandbox cheat codes.** Quantity `100` → instant auto-fill (`FILLED`). Quantity `999` → instant
  auto-reject (`REJECTED`). Use them to test your webhook handlers without waiting for the admin queue.
</Callout>

## Production execution behavior

Production orders are operationally constrained by the exchange, the broker, and the available market
data. Treat these guarantees as part of your client-state model:

<ParamTable fields={[
  { name: 'partial fills', type: 'behavior', required: false, desc: 'Orders may fill partially. Use order status plus execution reports to show filledQuantity, remainingQuantity, and averageFillPrice when present.' },
  { name: 'average price', type: 'field', required: false, desc: 'averageFillPrice is the weighted average of fills, not necessarily the quote price or the last market price.' },
  { name: 'slippage', type: 'guardrail', required: false, desc: 'MARKET orders execute at available market prices inside the configured deviation band (default +/-10%). Orders outside that band are rejected or left pending for manual handling.' },
  { name: 'halts and auctions', type: 'market state', required: false, desc: 'If an exchange is halted, in auction, closed, or missing a valid market price, MARKET orders are rejected or held pending; resting orders remain WORKING until normal trading resumes and their trigger is crossed.' },
  { name: 'order book depth', type: 'coverage', required: false, desc: 'The Partner API does not contractually expose Level-2 depth. Use quoted/last prices for retail UX, and design advanced depth views only after a market-data vendor agreement is in place.' },
]} />

## List & fetch orders

<MethodTag m="GET" /> `/orders` · <MethodTag m="GET" /> `/orders/{orderId}` · <MethodTag m="GET" /> `/users/{userId}/orders`

<TryEndpoint id="listOrders" />

List orders for the master account or a specific sub-account (cursor-paginated), or fetch a single
order by ID to poll status. Order fields include `status`, `rejectionCode`, `rejectionReason`, `fee`,
`baseFee`, `partnerMarkupFee`, and timestamps. Filter by `?status=PENDING`, `?symbol=`, or
`?from=`/`?to=` date range.

For sub-accounts, `?symbol=SCOM.KE` is an exact, case-insensitive filter and can be combined with
`status` in production or sandbox without requiring a database composite index.

```json
{ "orders": [{ "id": "ord_abc123", "symbol": "SCOM.KE", "type": "BUY", "status": "FILLED", "quantity": 1000, "priceAtOrder": 16.5, "totalAmount": 12.69, "feeAmount": 0.09, "currency": "USD", "rejectionCode": null, "settledAt": "2026-06-04T11:30:00Z", "createdAt": "2026-06-04T09:45:00Z" }], "count": 1, "hasMore": false, "nextCursor": null }
```

## Cancel an order

<MethodTag m="DELETE" /> `/orders/{orderId}` · <MethodTag m="DELETE" /> `/users/{userId}/orders/{orderId}`

<TryEndpoint id="deleteOrders" />

Cancel an order while it is still `PENDING` (market orders awaiting fill) or `WORKING` (resting limit/stop
orders). BUY escrow is refunded atomically. Returns HTTP 409 once the order has reached a terminal state
(`FILLED`, `CANCELLED`, `EXPIRED`, or `REJECTED`). A successful cancellation fires an `order.cancelled` webhook.

Sandbox uses the same cancellation behavior for active resting/simulator orders, including BUY cash
refunds and SELL unit-reservation release.

## Transaction ledger

<MethodTag m="GET" /> `/users/{userId}/transactions`

Full wallet ledger for a sub-account — every credit and debit in chronological order: deposits,
withdrawals, trade escrows (`INVEST`), trade proceeds (`SELL`), dividend distributions (`DISTRIBUTION`),
fund redemptions (`REDEEM`), and fees. Cursor-paginated.

`settledAt` is populated for completed transactions. For a legacy completed row created before that
field existed, the API returns its immutable completion/creation timestamp as compatibility evidence.

<ParamTable fields={[
  { name: 'type',   type: 'string',  required: false, desc: 'DEPOSIT | WITHDRAWAL | INVEST | SELL | DISTRIBUTION | REDEEM | FEE | TRANSFER_IN | TRANSFER_OUT' },
  { name: 'from',   type: 'date',    required: false, desc: 'On or after this date (YYYY-MM-DD, UTC).' },
  { name: 'to',     type: 'date',    required: false, desc: 'On or before this date (YYYY-MM-DD, UTC).' },
  { name: 'cursor', type: 'string',  required: false, desc: 'Opaque cursor from nextCursor. Omit for first page.' },
  { name: 'limit',  type: 'integer', required: false, desc: 'Page size, default 50, max 200.' },
]} />

```json
{ "transactions": [{ "id": "txn_abc123", "type": "DEPOSIT", "status": "COMPLETED", "direction": "CREDIT", "amount": 500, "currency": "USD", "reference": "dep_riven_user_42_1743152580", "createdAt": "2026-06-01T10:00:00Z" }, { "id": "txn_xyz789", "type": "INVEST", "status": "PENDING", "direction": "DEBIT", "amount": 64.13, "currency": "USD", "description": "BUY SCOM.KE x500", "reference": "ord_def456" }], "count": 2, "hasMore": false, "nextCursor": null }
```

## Portfolio

<MethodTag m="GET" /> `/portfolio` _(master)_ · <MethodTag m="GET" /> `/users/{userId}/portfolio` _(sub-account)_

Holdings with current market value, cost basis, unrealized P&L, and currency. Also includes fund and
bond positions if the account holds subscriptions.

```json
{ "walletBalance": 487.31, "totalValue": 512.31, "holdings": [{ "symbol": "SCOM.KE", "name": "Safaricom PLC", "quantity": 1000, "avgCostUsd": 0.01269, "currentPriceUsd": 0.0126, "marketValue": 12.6, "unrealizedPnl": -0.09, "currency": "KES" }] }
```
