## Market data
### GET /v1/marketdata/ltp — Last traded price

**Scope:** `marketdata.read`

Single or bulk.

#### Request

| Param | Type | Required | Notes |
|---|---|---|---|
| `i` | integer | yes | An `instrument_id` from the scrip master. Repeat the parameter once per instrument, up to 100 per request. |

#### Example

```http
GET /v1/marketdata/ltp?i=12027&i=11886&i=466828
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
```

#### Response — `200 OK`

The `data` object is keyed by `instrument_id` (string form because JSON object keys are always strings; the value originated as an integer).

```json
{
  "data": {
    "12027": {
      "last_trade_price": 251000,
      "last_trade_time": 1747798260123
    },
    "11886": {
      "last_trade_price": 412540,
      "last_trade_time": 1747798260110
    },
    "466828": {
      "last_trade_price": 15275,
      "last_trade_time": 1747798260087
    }
  }
}
```

Each per-instrument value:

| Field | Type | Notes |
|---|---|---|
| `last_trade_price` | integer | Paise. |
| `last_trade_time` | integer \| null | Epoch milliseconds. `null` for non-tradable instruments (e.g. indices). |

Instruments that don't exist are returned as `"data": { "5073182": null }`; the request as a whole still returns `200`. Validation failures (non-integer `i`, > 100 entries) return `400` with `VALIDATION_FAILED`.

---

### GET /v1/marketdata/quotes — Full quote with market depth

**Scope:** `marketdata.read`

Single or bulk. Returns the REST equivalent of the tick stream's `depth_tick` — price, volume, DPR band, open interest, and market depth.

`exchange_segment` / `instrument_type` (present on WS ticks) are not repeated here — both are static attributes of the instrument, available from the scrip master the `instrument_id` came from.

#### Request

| Param | Type | Required | Notes |
|---|---|---|---|
| `i` | integer | yes | An `instrument_id` from the scrip master. Repeat the parameter once per instrument, up to 100 per request — the same shape as `/v1/marketdata/ltp`. |

#### Example

```http
GET /v1/marketdata/quotes?i=12027&i=5073182
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
```

#### Response — `200 OK`

The `data` object is keyed by `instrument_id` (string form because JSON object keys are always strings; the value originated as an integer).

```json
{
  "data": {
    "12027": {
      "last_trade_price": 251000,
      "last_trade_time": 1747798260123,
      "volume_today": 12450,
      "dpr_upper": 276100,
      "dpr_lower": 225900,
      "prev_open_interest": null,
      "open_interest": null,
      "best_buys": [
        { "price": 250950, "qty": 100, "num_orders": 3 },
        { "price": 250900, "qty": 250, "num_orders": 7 }
      ],
      "best_sells": [
        { "price": 251050, "qty": 80, "num_orders": 2 }
      ]
    },
    "5073182": null
  }
}
```

Each per-instrument value:

| Field | Type | Notes |
|---|---|---|
| `last_trade_price` | integer | Paise. |
| `last_trade_time` | integer \| null | Epoch milliseconds. `null` for non-tradable instruments (e.g. indices). |
| `volume_today` | integer | Cumulative traded volume today. `0` for non-tradable instruments. |
| `dpr_upper`, `dpr_lower` | integer | Daily price range (circuit limit) bounds, paise. |
| `prev_open_interest`, `open_interest` | integer \| null | Open interest. `null` for non-derivative instruments; always present on derivatives, where `0` is a real value meaning zero open interest — not "unknown". |
| `best_buys`, `best_sells` | object[] | Order-book levels, best price first — only price levels holding orders are included, up to 5 or 20 per side depending on the instrument's feed type. Empty when no order book exists: before pre-market opens, immediately after the start-of-day reset, and on instruments with no resting orders. Levels are never zero-padded. |
| `best_buys[].price` | integer | Bid/ask price at the level, paise. |
| `best_buys[].qty` | integer | Total quantity resting at the level. |
| `best_buys[].num_orders` | integer | Number of orders at the level. |

Instruments that don't exist are returned as `null` (as `5073182` above); the request as a whole still returns `200`. Validation failures (non-integer `i`, > 100 entries) return `400` with `VALIDATION_FAILED`.

---

### GET /v1/marketdata/candles — Historical OHLC

**Scope:** `marketdata.read`

One instrument per request. Supported `size` values: `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `1d`. Labels are case-sensitive — `1M` is **not** one minute and is rejected.

#### Request

| Param | Type | Required | Notes |
|---|---|---|---|
| `instrument_id` | integer | yes | Scrip-master ID. |
| `size` | string | yes | One of `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `1d`. |
| `from` | integer | yes | Window start, epoch milliseconds, inclusive. |
| `to` | integer | yes | Window end, epoch milliseconds, inclusive. Must satisfy `to >= from`. |
| `include_partial` | boolean | no | Default `false`. When `true` and the market is open, the in-progress candle for the current window is appended as the last entry. |
| `oi` | boolean | no | Default `false`. When `true`, each candle carries open interest as a 7th array element (see below). |

#### Example

```http
GET /v1/marketdata/candles?instrument_id=12027&size=1m&from=1747798200000&to=1747820100000
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
```

#### Response — `200 OK`

```json
{
  "instrument_id": 12027,
  "size": "1m",
  "candles": [
    [1747798200000, 250000, 250125, 249850, 249975, 12450],
    [1747798260000, 249975, 250050, 249900, 250000, 8120]
  ],
  "has_more": false
}
```

Each candle is a fixed-length array. By default it has 6 elements — `[timestamp, open, high, low, close, volume]`. When the request sets `oi=true`, a 7th element (`open_interest`) is appended to every candle. The width is determined solely by the request flag, so every candle in a given response has the same length.

| Index | Field | Present | Notes |
|---|---|---|---|
| 0 | `timestamp` | always | Candle open-time, integer epoch milliseconds, aligned to the window (e.g. for `1m`: 09:15:00 IST, 09:16:00 IST, and so on). |
| 1–4 | `open`, `high`, `low`, `close` | always | Integer paise. |
| 5 | `volume` | always | Quantity traded in the window, in units. `0` for windows with no trades — in that case `open`, `high`, `low` and `close` all repeat the previous window's `close`. |
| 6 | `open_interest` | only when `oi=true` | Integer open interest; `0` for non-derivative instruments. May carry forward across zero-volume windows for derivatives. |

With `oi=true` each candle is a 7-element array; the appended element is the integer open interest (`0` for non-derivative instruments). For example, a futures instrument:

```json
"candles": [
  [1747820040000, 1840000, 1841500, 1839000, 1840500, 5120, 34500],
  [1747820100000, 1840500, 1842000, 1840000, 1841750, 4880, 34620]
]
```

Candles are returned in ascending order of `timestamp` (index 0). The series covers every window in `[from, to]` — windows with no trades are included with a `0` volume (index 5) so you do not have to interpolate missing buckets.

A response holds at most **2500 candles** per request. When the requested range covers more windows than that, the response carries the **most recent 2500** and the older part of the range is omitted — the series is clipped at its *start*, not its end. `has_more` is `true` exactly when that happened.

To walk a longer history, page **backward**: set `to` to the first returned `timestamp - 1` and re-fetch, repeating until `has_more` is `false`. Advancing `from` does not page — the returned series already ends at `to`.

An unknown `instrument_id` returns `200` with an empty `candles` array. Validation failures (unsupported `size`, inverted range, non-integer parameters) return `400` with `VALIDATION_FAILED`.

---

### GET /v1/marketdata/optionchain/expiries — Option expiries

**Scope:** `marketdata.read`

The expiry dates an [option chain](#get-v1marketdataoptionchain--option-chain) can be requested for: the unexpired expiries the underlying has listed option contracts on within the scrip master's horizon — 93 days ahead on NSE, 63 on BSE, 123 on MCX and NCOM — nearest first. Longer-dated contracts the exchange lists are not in the scrip master and are not served here. An expiry stays listed through its own expiry day until the F&O session closes.

#### Request

| Param | Type | Required | Notes |
|---|---|---|---|
| `underlying_id` | integer | yes | The **underlying's** `instrument_id` from the scrip master — the index or stock, not a derivative. |

#### Example

```http
GET /v1/marketdata/optionchain/expiries?underlying_id=10854
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
```

#### Response — `200 OK`

```json
{
  "expiries": ["2026-10-27", "2026-11-03", "2026-12-29"]
}
```

Expiries are calendar dates in IST, `YYYY-MM-DD` — the one place a date is not an epoch-millisecond timestamp, because an expiry is a trading day, not an instant. An `underlying_id` with no listed options returns `200` with an empty array. A missing or non-integer `underlying_id` returns `400` with `VALIDATION_FAILED`.

---

### GET /v1/marketdata/optionchain — Option chain

**Scope:** `marketdata.read`

The full option chain for one underlying and expiry: every strike with its call and put leg — price, open interest, volume, top of book, implied volatility and greeks — in one request.

Quote fields are live feed snapshots. Implied volatility and greeks come from Sahi's market-analytics service — the same numbers the Sahi app's option chain shows — and are `null` for the chains it does not track.

#### Request

| Param | Type | Required | Notes |
|---|---|---|---|
| `underlying_id` | integer | yes | The **underlying's** `instrument_id` from the scrip master. |
| `expiry` | string | no | `YYYY-MM-DD`, one of the [expiries endpoint](#get-v1marketdataoptionchainexpiries--option-expiries)'s values. Omitted, the nearest unexpired expiry is used. |

#### Example

```http
GET /v1/marketdata/optionchain?underlying_id=10854&expiry=2026-12-29
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
```

#### Response — `200 OK`

```json
{
  "underlying_id": 10854,
  "expiry": "2026-12-29",
  "last_price": 2564280,
  "strikes": [
    {
      "strike": 2500000,
      "ce": {
        "instrument_id": 466828,
        "last_trade_price": 43078,
        "last_trade_time": 1758790260123,
        "average_price": 42150,
        "volume_today": 1245600,
        "open_interest": 2354200,
        "prev_open_interest": 2100800,
        "prev_close_price": 41260,
        "top_bid_price": 43050,
        "top_bid_qty": 650,
        "top_ask_price": 43100,
        "top_ask_qty": 520,
        "implied_volatility": 12.34,
        "greeks": {
          "delta": 0.5706,
          "gamma": 0.00044,
          "theta": -6.04,
          "vega": 28.38
        }
      },
      "pe": null
    }
  ]
}
```

Top level:

| Field | Type | Notes |
|---|---|---|
| `underlying_id` | integer | Echoes the request. |
| `expiry` | string | The resolved expiry, `YYYY-MM-DD` (IST) — useful when the request omitted it. |
| `last_price` | integer \| null | The underlying's last-traded price, paise. `null` when no snapshot exists for it. |
| `strikes` | object[] | One row per strike, ascending by strike price. |

Each strike row:

| Field | Type | Notes |
|---|---|---|
| `strike` | integer | Strike price, paise. |
| `ce`, `pe` | object \| null | The call / put leg. `null` when no such contract is listed at this strike or the feed has no snapshot for it yet. |

Each leg:

| Field | Type | Notes |
|---|---|---|
| `instrument_id` | integer | The option contract — tradable via the [order APIs](/api/v1/reference/orders/) as-is. |
| `last_trade_price` | integer | Paise. |
| `last_trade_time` | integer \| null | Epoch milliseconds. `null` before the contract's first trade. |
| `average_price` | integer | Day's average traded price, paise. |
| `volume_today` | integer | Cumulative traded volume today. |
| `open_interest`, `prev_open_interest` | integer \| null | Current / previous-day open interest. |
| `prev_close_price` | integer | Previous session's close, paise. |
| `top_bid_price`, `top_bid_qty` | integer \| null | Best bid and the quantity resting at it, paise. `null` when the bid book is empty. |
| `top_ask_price`, `top_ask_qty` | integer \| null | Best ask and the quantity resting at it. `null` when the ask book is empty. |
| `implied_volatility` | number \| null | Percent (e.g. `12.34` = 12.34%). `null` when the analytics service does not track this chain, or has not solved this leg — a leg with no premium yet, for instance. |
| `greeks` | object \| null | Present when `implied_volatility` is. |
| `greeks.delta` | number | Price change per 1.0 move of the underlying. Calls in `[0, 1]`, puts in `[-1, 0]`. |
| `greeks.gamma` | number | Delta change per 1.0 move of the underlying. |
| `greeks.theta` | number | Price change per **day** of time decay, in the premium's units (paise-denominated rupee terms match `last_trade_price / 100`). |
| `greeks.vega` | number | Price change per **1%** move in implied volatility. |

There is no `previous_volume` field — the live feed does not carry the prior session's volume. Derive it from the [candles endpoint](#get-v1marketdatacandles--historical-ohlc) (`size=1d`) if you need it.

An `expiry` that isn't a listed, unexpired expiry for the underlying — or an `underlying_id` with no listed options — returns `400` with `VALIDATION_FAILED`. Malformed parameters (non-integer `underlying_id`, an `expiry` not in `YYYY-MM-DD` form) return `400` with `VALIDATION_FAILED`. A chain the analytics service does not track still returns `200`, with `implied_volatility` and `greeks` null on every leg; the feed or the analytics service being unreachable returns `500` with `INTERNAL`.

---

### WSS /v1/stream/marketdata — Tick stream

**Scope:** `marketdata.read`

Three modes select the field subset sent on the wire.

Subscription state is **connection-local** and is not persisted across disconnects. After any reconnect (`4401`, `4408`, or otherwise) the client must re-send its `Subscribe` messages — the server starts a new connection with an empty subscription set.

There is nothing to reconcile on this stream. Every tick is a complete snapshot for its instrument at the currently-effective mode, so the newest one you hold is the answer and a duplicate costs nothing — no REST snapshot, no per-key merge. After a reconnect the next tick per instrument brings the value back up to date; if you need one before that tick arrives, read it once from [`GET /v1/marketdata/ltp`](#get-v1marketdataltp--last-traded-price) or [`GET /v1/marketdata/quotes`](#get-v1marketdataquotes--full-quote-with-market-depth).

#### Wire format

Binary WebSocket frames. Each frame carries exactly one `ClientMessage` (client → server) or `ServerMessage` (server → client), serialised as protobuf wire format.

#### Schema

```proto
syntax = "proto3";

package sahi.direct.marketdata.v1;

// Enums

enum ExchangeSegment {
  EXCHANGE_SEGMENT_INVALID = 0;
  EXCHANGE_SEGMENT_NSE = 1;
  EXCHANGE_SEGMENT_BSE = 2;
  EXCHANGE_SEGMENT_NFO = 3;
  EXCHANGE_SEGMENT_BFO = 4;
}

enum InstrumentType {
  INSTRUMENT_TYPE_INVALID = 0;
  INSTRUMENT_TYPE_INDEX = 1;
  INSTRUMENT_TYPE_STOCK = 2;
  INSTRUMENT_TYPE_ETF = 3;
  INSTRUMENT_TYPE_INDEX_FUT = 4;
  INSTRUMENT_TYPE_INDEX_OPT_CE = 5;
  INSTRUMENT_TYPE_INDEX_OPT_PE = 6;
  INSTRUMENT_TYPE_STOCK_FUT = 7;
  INSTRUMENT_TYPE_STOCK_OPT_CE = 8;
  INSTRUMENT_TYPE_STOCK_OPT_PE = 9;
}

enum Mode {
  MODE_INVALID = 0;
  MODE_LTP = 1;
  MODE_QUOTE = 2;
  MODE_DEPTH = 3;
}

// Client -> server

message ClientMessage {
  oneof payload {
    Subscribe   subscribe   = 1;
    Unsubscribe unsubscribe = 2;
    ModeChange  mode_change = 3;
  }
}

message Subscribe {
  Mode mode = 1;
  repeated int64 instrument_ids = 2;
}

message Unsubscribe {
  repeated int64 instrument_ids = 1;
}

message ModeChange {
  Mode mode = 1;
  repeated int64 instrument_ids = 2;
}

// Server -> client

message ServerMessage {
  oneof payload {
    Ack         ack        = 1;
    LtpTick     ltp_tick   = 2;
    QuoteTick   quote_tick = 3;
    DepthTick   depth_tick = 4;
    StreamError error      = 5;
  }
}

message Ack {
  enum Action {
    ACTION_INVALID = 0;
    ACTION_SUBSCRIBE = 1;
    ACTION_UNSUBSCRIBE = 2;
    ACTION_MODE_CHANGE = 3;
  }
  Action action = 1;
  repeated int64 instrument_ids = 2;
}

message StreamError {
  string code = 1;
  string message = 2;
}

message LtpTick {
  int64 instrument_id = 1;
  ExchangeSegment exchange_segment = 2;
  InstrumentType instrument_type = 3;
  int64 last_trade_price = 4;          // paise
  optional int64 last_trade_time = 5;  // epoch ms; absent for indices
}

message QuoteTick {
  int64 instrument_id = 1;
  ExchangeSegment exchange_segment = 2;
  InstrumentType instrument_type = 3;
  int64 last_trade_price = 4;
  optional int64 last_trade_time = 5;
  int64 volume_today = 6;
  int64 dpr_upper = 7;
  int64 dpr_lower = 8;
  optional int64 prev_open_interest = 9;  // derivative-only
  optional int64 open_interest = 10;      // derivative-only
}

message DepthTick {
  int64 instrument_id = 1;
  ExchangeSegment exchange_segment = 2;
  InstrumentType instrument_type = 3;
  int64 last_trade_price = 4;
  optional int64 last_trade_time = 5;
  int64 volume_today = 6;
  int64 dpr_upper = 7;
  int64 dpr_lower = 8;
  optional int64 prev_open_interest = 9;
  optional int64 open_interest = 10;
  // Only levels holding orders; up to 5 or 20 per side depending on the
  // instrument's feed type (L2 vs L3). Never zero-padded.
  repeated DepthLevel best_buys = 11;
  repeated DepthLevel best_sells = 12;
}

message DepthLevel {
  int64 price = 1;       // paise
  int64 qty = 2;
  int64 num_orders = 3;
}
```

#### Enums

| Proto enum | Values (prefix stripped) |
|---|---|
| `ExchangeSegment` | `NSE`, `BSE`, `NFO`, `BFO` |
| `InstrumentType` | `INDEX`, `STOCK`, `ETF`, `INDEX_FUT`, `INDEX_OPT_CE`, `INDEX_OPT_PE`, `STOCK_FUT`, `STOCK_OPT_CE`, `STOCK_OPT_PE` |
| `Mode` | `LTP`, `QUOTE`, `DEPTH` |

Each value in the schema above is prefixed with its enum name (e.g. `EXCHANGE_SEGMENT_NSE`); the table strips the prefix for readability.

#### Client → server (`ClientMessage` oneof)

| Variant | Fields | Meaning |
|---|---|---|
| `subscribe` | `mode`, `instrument_ids` | Add `instrument_ids` at the given `mode`. Idempotent. |
| `unsubscribe` | `instrument_ids` | Remove `instrument_ids` from the connection. |
| `mode_change` | `mode`, `instrument_ids` | Change mode for already-subscribed `instrument_ids`. |

#### Server → client (`ServerMessage` oneof)

| Variant | Meaning |
|---|---|
| `ack` | Server accepted a `subscribe` / `unsubscribe` / `mode_change`. Echoes the action and accepted `instrument_ids`. |
| `ltp_tick` / `quote_tick` / `depth_tick` | A snapshot for one instrument at the currently-effective mode. |
| `error` | Non-fatal error (e.g. unknown `instrument_id`, mode change on an unsubscribed id). Carries `code` and `message`; the connection stays open. |

Every tick variant carries the same identity fields (`instrument_id`, `exchange_segment`, `instrument_type`, `last_trade_price`, `last_trade_time`). Higher modes add more.

| Variant | Mode | Additional fields beyond the identity set |
|---|---|---|
| `LtpTick` | `LTP` | *(none)* |
| `QuoteTick` | `QUOTE` | `volume_today`, `dpr_upper`, `dpr_lower`, `prev_open_interest`, `open_interest` |
| `DepthTick` | `DEPTH` | `QuoteTick` fields **plus** repeated `best_buys` and `best_sells`. Each `DepthLevel` array carries only price levels holding orders, up to 5 or 20 depending on the instrument's feed type (L2 vs L3) — never zero-padded. |

#### Field semantics

Price fields (`last_trade_price`, `dpr_upper`, `dpr_lower`, `DepthLevel.price`) are integer **paise**. Quantities (`volume_today`, `prev_open_interest`, `open_interest`, `DepthLevel.qty`, `DepthLevel.num_orders`) are integer counts. `last_trade_time` is integer epoch milliseconds.

`last_trade_time` is absent (proto3 `optional`, no value) for non-tradable instruments (indices). `prev_open_interest` and `open_interest` are absent for non-derivative instruments. `volume_today` is `0` before the first trade of the day and after the start-of-day reset, then accumulates through the session.

`dpr_upper` and `dpr_lower` are the exchange-imposed **Daily Price Range** bounds (also called circuit limits) in paise — the upper and lower price band within which the exchange will accept orders for this instrument on the current trading day. Orders priced outside this band are rejected by the exchange.

#### Per-connection limits

- Maximum subscribed `instrument_ids` per connection: `1000` by default, configurable per account.
- Maximum concurrent connections per token: `5`. The next is refused with close code `4429`; see [Rate limits](/#rate-limits).
- Mode changes are per-`instrument_id`; a connection can hold a mix of modes simultaneously.

#### Close codes

| Code | Meaning |
|---|---|
| `1000` | Normal closure. |
| `4401` | Auth expired. |
| `4403` | Forbidden — token lacks `marketdata.read`. In practice an under-scoped (or otherwise invalid) token is rejected at the HTTP upgrade handshake with `403`, before the WebSocket is established; this close code is reserved for scope failures detected after the upgrade. |
| `4408` | Ping timeout — no pong received within 15s of the server ping. |
| `4429` | Per-token concurrent-connection limit exceeded — [5 open connections](/#rate-limits). |

