DhanHQ 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d79f2577f59f6c04874eb44e4d318e5584e711e205be25f6fa000053ec772846
4
- data.tar.gz: dca8144795ab2b77700a3947512c04e73c8edcbaa287b74adb746e671158e583
3
+ metadata.gz: 2929a9a1a182987ee1a71b822d28db5879919348e6598a545d2133d86721d82d
4
+ data.tar.gz: afc1857745b80f145d5f2aa0e6f3445ec5c5c0adbc43de58bfddda58f1e8f33f
5
5
  SHA512:
6
- metadata.gz: e5a38d2663377da284188dc32fd0e796394ee899a34b61d5f82b5a487b0a47477a9e6bc1c0fdfc0b7fa93959d8673e257a83b7eea1af1a48885526ecc506a89f
7
- data.tar.gz: 3f2023c47fc5e5b1f6fcce29f704c2c4ac03ad887a91dbc0c33bba201a151e8f03793bdc736915a295e98a5e661810e6229e32b37ea11c09b977fbcbb1b1c5d3
6
+ metadata.gz: 2bc9cbb45c476e9372673549ff6f6a92605dae6408b986a08b4f4944a8dcba8e02587cfd9098011159ad3a00ce43ec1e6dbf50c7b0b7ae38bb3daac272667b05
7
+ data.tar.gz: b0acc9f195069adc8a6a38b1faf5ddd09d4c189d78a64fc0eb78e363477988eebfeabc7521b9cc5d7ddc1f52d221c6b427a5e9c2a652b6eefeba25ae84e00e83
data/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
1
+ ## [4.1.1] - 2026-10-11
2
+
3
+ ### Fixed — pre-trade risk pipeline
4
+
5
+ - **The risk checks only saw parameters whose key happened to be a single lowercase word.** Checks read snake_case String keys (`"order_type"`, `"product_type"`, `"security_id"`), but orders arrive as camelCase Strings (models) or Symbols (direct resource calls). `OrderType`, `ProductSupport` and `Concentration` therefore never ran on real orders, and Symbol-keyed callers failed `Quantity must be > 0` on a valid quantity — which blocked every `SuperOrder.create`. `OrderAudit` now normalises keys before running the pipeline.
6
+ - **Risk-reducing orders are never blocked by a limit.** An order on the opposite side of an open position, same security / segment / product, for no more than the position size, skips the size caps, open-position count, daily loss, concentration and ASM/GSM checks; it still gets trading-permission, product, order-type, quantity > 0 and market-hours validation. Previously a stop-out could be refused precisely because the account was over its loss or position limit.
7
+ - **`OrderType` accepts `STOP_LOSS` and `STOP_LOSS_MARKET`** (was `MARKET`/`LIMIT` only). With the key fix above, the old list would have rejected every stop-loss order.
8
+ - **`Concentration` matches positions by security id** (and segment) when the order names no trading symbol, and counts the new order's notional; it compared a trading symbol against a security id and so never matched an order. A MARKET entry (which carries no price) is priced from its trigger price or the instrument's LTP; an entry that cannot be priced is refused rather than counted as zero exposure.
9
+ - **`MaxLoss` counts realized plus unrealized P&L** for the day (was unrealized only).
10
+ - **Positions are fetched once per order** and shared by every check (was up to three `GET /v2/positions` calls per order).
11
+ - **`SuperOrder.create`, `IcebergOrder.create` and `TwapOrder.create` raised `ArgumentError` after a successful placement** (Ruby 3 keyword arguments: the attributes hash was splatted into keywords). The order existed at Dhan while the caller saw an exception — a retry would duplicate it. The super-order spec stubbed `.new` with the same broken signature, which hid it.
12
+
13
+ ### Fixed — endpoint wire format and error mapping
14
+
15
+ - **Data-API error codes sent as integers never matched the error mapping.** Dhan's data APIs answer with numeric codes (`{"errorCode":807,"errorMessage":"Token expired"}` — see `spec/vcr_cassettes/client/error_806.yml`), but `ResponseHelper#handle_error` compared them against string-keyed `DHAN_ERROR_MAPPING`, so every integer code fell through to the HTTP-status fallback: token expiry (807) surfaced as `InvalidAuthenticationError`, invalid expiry date (811) as a generic `InputExceptionError`, and so on. The code is now normalized with `.to_s` before the lookup. New wire-format specs in `spec/dhan_hq/resources/historical_data_spec.rb` and `expired_options_data_spec.rb` cover the mapping.
16
+ - **`Resources::Instruments#by_segment` swallowed HTTP errors.** On 4xx/5xx it returned the raw JSON error body as if it were CSV — no exception, unlike every other resource. Failures now raise the status-mapped `DhanHQ::Error` subclass (`NotFoundError`, `InvalidAuthenticationError`, …). New spec in `spec/dhan_hq/resources/instruments_spec.rb`.
17
+ - **`Resources::MarginCalculator#calculate_multi` sent nested `scripList` entries in snake_case.** Only top-level keys were camelized, so the API received `security_id`/`exchange_segment` inside each scrip (the `Models::Margin` layer pre-camelized as a workaround; the raw resource did not). The resource now deep-camelizes like `multi_orders` does for its legs. New spec in `spec/dhan_hq/resources/margin_calculator_spec.rb`.
18
+ - New WebMock wire-format specs for every non-order REST endpoint — funds, holdings, trades, positions, profile, statements, market feed (ltp/ohlc/quote), option chain, charts, expired options, margin calculator, instruments, EDIS, IP setup, kill switch — asserting method, path, headers, body shape, param camelization/titleization, and typed error mapping. (Orders endpoints excluded per scope.)
19
+
20
+ ### Fixed — authentication
21
+
22
+ - **`DhanHQ::Auth.renew_token` called the wrong URL.** `conn.post("/RenewToken")` on the `https://api.dhan.co/v2` base made Faraday drop `/v2` (a leading slash is an absolute path), so renewals hit `https://api.dhan.co/RenewToken`. It now requests `/v2/RenewToken` with **GET**, matching Dhan's documented curl example (`dhanhq.co/docs/v2/authentication`) and the official Python SDK (`dhanhq` 2.3.0). `generate_access_token` also uses a relative path now.
23
+ - **Successful auth responses returned `{}`.** The auth connection had no JSON parsing, so the body arrived as a string and `handle_response` discarded it — the new `accessToken` was lost on every successful renewal or generation. Bodies are now parsed regardless of `Content-Type`.
24
+ - **Auth failures are typed by cause.** 429 raises `DhanHQ::RateLimitError`, 5xx `DhanHQ::InternalServerError`, timeouts and connection failures `DhanHQ::NetworkError`; only refused credentials raise `DhanHQ::InvalidAuthenticationError`. Previously every failure, including outages, looked like bad credentials. All remain `DhanHQ::Error` subclasses, and `response_body` now carries the parsed error payload.
25
+ - **Auth requests have timeouts** (`DHAN_CONNECT_TIMEOUT` / `DHAN_READ_TIMEOUT` / `DHAN_WRITE_TIMEOUT`, defaults 10/30/30s, as `Client#build_connection`). They were unbounded.
26
+ - New `spec/dhan_hq/auth_spec.rb` covers both endpoints, error mapping, timeouts and `Auth::TokenRenewal`; the auth module previously had no specs.
27
+
28
+ ### Added
29
+
30
+ - **Configurable risk limits** — `config.risk_limits = { max_quantity:, max_notional:, max_open_positions:, daily_max_loss:, max_concentration_pct: }` or `DHAN_RISK_MAX_QUANTITY`, `DHAN_RISK_MAX_NOTIONAL`, `DHAN_RISK_MAX_OPEN_POSITIONS`, `DHAN_RISK_DAILY_MAX_LOSS`, `DHAN_RISK_MAX_CONCENTRATION_PCT`. Defaults are unchanged (10 units, ₹1,00,000 notional, 20 positions, ₹50,000 daily loss, 25%). Unknown names or non-positive values raise `ArgumentError` at configuration time.
31
+
32
+ ### Upgrade notes
33
+
34
+ - **`max_quantity` defaults to 10 units, so one NIFTY lot (75) is rejected** on any order whose instrument resolves. This was already true for camelCase-keyed orders before this release; it now applies to every key style. F&O users must raise `max_quantity` (in units, not lots) and usually `max_notional`.
35
+ - Checks that never ran before now run on every order: `OrderType`, `ProductSupport` (CO/BO support flags) and `Concentration` (one extra `GET /v2/fundlimit` per new-exposure order).
36
+ - A risk-check failure still fails open: if the instrument lookup or a positions/funds fetch raises, the pipeline is skipped and logged as `DHAN_RISK_CHECK_SKIPPED` (unchanged behaviour).
37
+
38
+ ### Fixed (review against docs.dhanhq.co, dhanhq.co/docs/v2 and the live/sandbox OpenAPI documents)
39
+
40
+ - **`:hybrid` mode sent the production token to the sandbox host.** Dhan runs sandbox and production with separate credentials and rejects each other's tokens (docs.dhanhq.co, *Sandbox*), so every sandbox-bound request in `:hybrid` was a guaranteed DH-901. New `sandbox_client_id` / `sandbox_access_token` (`DHAN_SANDBOX_CLIENT_ID` / `DHAN_SANDBOX_ACCESS_TOKEN`) are used for every request routed to the sandbox — `access-token` and `client-id` headers, and a body `dhanClientId` that equals the production ID. In `:sandbox` mode they fall back to `client_id` / `access_token`, so existing setups are unaffected; in `:hybrid` they are required and their absence raises `DhanHQ::AuthenticationError` before any I/O.
41
+ - **Sandbox-unsupported endpoints now fail fast.** Requests routed to the sandbox for super orders, conditional/multi orders, P&L exit, multi-scrip margin, IP setup, ScanX, market quotes, option chain, rolling options and Global Stocks raise `DhanHQ::SandboxUnsupportedError` instead of returning an opaque 404. They are never redirected to production. The list (`Constants::SANDBOX_UNSUPPORTED_PATH_PREFIXES`) is the set of paths that are absent from the sandbox OpenAPI document and answer 404 on `sandbox.dhan.co`.
42
+ - **Full Market Depth (20/200-level) WebSocket never emitted data.** The decoder parsed frames as JSON, while Dhan sends binary packets (12-byte header plus 16-byte `float64`/`uint32`/`uint32` rows, bid and ask as separate stacked packets). Rewritten as a binary decoder; the client merges each instrument's bid and ask packets into one `:depth_update` (`bids`, `asks`, `best_bid`, `best_ask`, `spread`, totals) and emits `:disconnect` for server disconnect packets (code 50).
43
+ - **Market depth unsubscribe request code is now 25** (was 24). docs.dhanhq.co gives 25 in both its Annexure and the Full Market Depth page; the older dhanhq.co/docs/v2 Annexure says 24. Not verified against the live feed (it is geo-restricted from CI).
44
+ - **200-level depth subscription uses the documented flat format** (`{RequestCode, ExchangeSegment, SecurityId}`), not the 20-level instrument list, and a second instrument on a 200-level connection is refused (one instrument per connection). 20-level connections cap at 50 instruments.
45
+ - **`MarketDepth::Client#subscribe(hash)`** subscribed nothing — `Array(hash)` split the instrument hash into key/value pairs.
46
+ - **`OrderStatus::INACTIVE`** added; it is in the documented `orderStatus` response enum.
47
+
1
48
  ## [4.1.0] - 2026-10-10
2
49
 
3
50
  ### Fixed (full-gem review pass — `ruby-agent-skills` audit)
data/GUIDE.md CHANGED
@@ -103,6 +103,9 @@ live market data from `api.dhan.co`, paper execution on `sandbox.dhan.co`:
103
103
  ```ruby
104
104
  DhanHQ.configure do |config|
105
105
  config.mode = :hybrid # or DHAN_MODE=hybrid
106
+ # Sandbox credentials are separate from production ones (cloud.dhanhq.co)
107
+ config.sandbox_client_id = ENV["DHAN_SANDBOX_CLIENT_ID"]
108
+ config.sandbox_access_token = ENV["DHAN_SANDBOX_ACCESS_TOKEN"]
106
109
  # Optional: per-tier escape hatch on top of the mode
107
110
  # config.environment_overrides = { non_trading_api: :live }
108
111
  end
data/README.md CHANGED
@@ -264,9 +264,11 @@ surface) without risking money.
264
264
 
265
265
  ```ruby
266
266
  DhanHQ.configure do |config|
267
- config.client_id = ENV["DHAN_CLIENT_ID"]
268
- config.access_token = ENV["DHAN_ACCESS_TOKEN"]
269
- config.mode = :hybrid # or :live (default) / :sandbox
267
+ config.client_id = ENV["DHAN_CLIENT_ID"] # production — market data
268
+ config.access_token = ENV["DHAN_ACCESS_TOKEN"]
269
+ config.sandbox_client_id = ENV["DHAN_SANDBOX_CLIENT_ID"] # sandbox — execution
270
+ config.sandbox_access_token = ENV["DHAN_SANDBOX_ACCESS_TOKEN"] # (generate on cloud.dhanhq.co)
271
+ config.mode = :hybrid # or :live (default) / :sandbox
270
272
  end
271
273
 
272
274
  # Same code, split routing:
@@ -284,6 +286,18 @@ config.environment_overrides = { non_trading_api: :live } # e.g. read the real
284
286
  ```
285
287
 
286
288
  - Env vars: `DHAN_MODE=live|sandbox|hybrid` (or the legacy `DHAN_SANDBOX=true`).
289
+ - **Two credential sets.** Dhan's sandbox and production are separate environments and
290
+ each rejects the other's tokens, so `:hybrid` needs both: sandbox-bound requests
291
+ authenticate with `DHAN_SANDBOX_CLIENT_ID` / `DHAN_SANDBOX_ACCESS_TOKEN` (header *and*
292
+ body `dhanClientId`), production-bound ones with `DHAN_CLIENT_ID` / `DHAN_ACCESS_TOKEN`.
293
+ Missing sandbox credentials raise `DhanHQ::AuthenticationError` before any request.
294
+ In `:sandbox` mode the sandbox pair falls back to the primary pair, so existing setups
295
+ keep working.
296
+ - **Endpoints the sandbox does not serve** (super orders, conditional/multi orders, P&L
297
+ exit, multi-scrip margin, IP setup, ScanX, market quotes, option chain, rolling options,
298
+ Global Stocks) raise `DhanHQ::SandboxUnsupportedError` when routed there — they are
299
+ never silently sent to production. Route a read-only tier to production with
300
+ `environment_overrides` if you need it.
287
301
  - The `LIVE_TRADING` guard is **relaxed automatically** for requests routed to the
288
302
  sandbox host — no real money can move there. Orders still hit the guard on `:live`.
289
303
  - Per-client override: `DhanHQ::Client.new(config: shared_config, mode: :hybrid)` forks
@@ -77,7 +77,18 @@ config.access_token_provider = lambda do
77
77
  end
78
78
  ```
79
79
 
80
- **Note:** RenewToken is only for tokens generated from Dhan Web. For API key or Partner flows, obtain a new token using Dhan’s consent APIs in your app.
80
+ **Note:** RenewToken is only for tokens generated from Dhan Web, and only while the token is still active: renewing an expired token returns an error, so renew before expiry and fall back to `generate_access_token` (PIN + TOTP) if renewal is refused. For API key or Partner flows, obtain a new token using Dhan’s consent APIs in your app.
81
+
82
+ `renew_token` and `generate_access_token` raise by failure class, so callers can tell "retry later" from "re-authenticate":
83
+
84
+ | Error | When |
85
+ |---|---|
86
+ | `DhanHQ::InvalidAuthenticationError` | Dhan refused the token or credentials (4xx other than 429) |
87
+ | `DhanHQ::RateLimitError` | 429 — back off and retry |
88
+ | `DhanHQ::InternalServerError` | 5xx — retry later |
89
+ | `DhanHQ::NetworkError` | Timeout or connection failure — retry later |
90
+
91
+ All inherit from `DhanHQ::Error`, and `error.response_body` holds Dhan's parsed error payload.
81
92
 
82
93
  ## Retry-on-401
83
94
 
@@ -193,7 +204,7 @@ Behavior:
193
204
  On first use: calls TOTP TokenGenerator → applies token.
194
205
  Before each request: ensure_valid_token!:
195
206
  If no token → generate.
196
- If needs_refresh? → TokenRenewal (POST /v2/RenewToken) with current token + dhanClientId.
207
+ If needs_refresh? → TokenRenewal (GET /v2/RenewToken) with current token + dhanClientId.
197
208
  If renewal fails with auth error → falls back to full generate.
198
209
 
199
210
  6️⃣ Web-token renewal only (RenewToken)
@@ -25,6 +25,13 @@ Set these _before_ calling `configure_with_env` to override defaults:
25
25
  | Variable | Default | Description |
26
26
  | -------------------------------- | ---------- | ------------------------------------------------------- |
27
27
  | `DHAN_MODE` | `live` | `live`, `sandbox`, or `hybrid`. `hybrid` routes market data (quotes, option chain, charts, instruments, ScanX) to production and trading/account calls (orders, positions, funds, statements) to `https://sandbox.dhan.co/v2` — live prices, paper execution. Takes precedence over `DHAN_SANDBOX`. |
28
+ | `DHAN_RISK_MAX_QUANTITY` | `10` | Pre-trade cap on order quantity, in units (one NIFTY lot = 75). Applies to new exposure only; exits are never capped. Also `config.risk_limits[:max_quantity]`. |
29
+ | `DHAN_RISK_MAX_NOTIONAL` | `100000` | Cap on quantity × price for new exposure. |
30
+ | `DHAN_RISK_MAX_OPEN_POSITIONS` | `20` | New entries are refused at this many open positions. |
31
+ | `DHAN_RISK_DAILY_MAX_LOSS` | `50000` | New entries are refused once realized + unrealized day P&L is below minus this value. |
32
+ | `DHAN_RISK_MAX_CONCENTRATION_PCT`| `25` | Max exposure in one instrument (open position + this order) as % of available funds. |
33
+ | `DHAN_SANDBOX_CLIENT_ID` | unset | Client ID for `https://sandbox.dhan.co/v2` (Dhan Cloud). Used for every request routed to the sandbox. Required in `hybrid`; falls back to `DHAN_CLIENT_ID` in `sandbox` mode. |
34
+ | `DHAN_SANDBOX_ACCESS_TOKEN` | unset | Sandbox access token. Dhan rejects production tokens on the sandbox and vice versa. Required in `hybrid`; falls back to `DHAN_ACCESS_TOKEN` in `sandbox` mode. |
28
35
  | `DHAN_SANDBOX` | `false` | Legacy alias: set `"true"` to route all REST calls to `https://sandbox.dhan.co/v2`. Note: Dhan's sandbox validates request/response plumbing only — it does not execute real order fills/matching. Placed orders stay `PENDING` indefinitely. See [ENDPOINTS_AND_SANDBOX.md](ENDPOINTS_AND_SANDBOX.md). |
29
36
  | `DHAN_ENV_OVERRIDES` | unset | String-form per-tier overrides on top of the mode, e.g. `"non_trading_api=live,data_api=sandbox"`. Equivalent to `config.environment_overrides`. |
30
37
  | `DHAN_BASE_URL` | Dhan prod | Point REST calls to a different API hostname. Takes precedence over `DHAN_SANDBOX` only when explicitly set to something other than the production default. |
@@ -31,6 +31,24 @@ client = DhanHQ::Client.new(config: shared_config, mode: :hybrid)
31
31
  Resolution order for any request: explicit `config.base_url` (if set, it wins for every
32
32
  request) → `environment_overrides[tier]` → mode default for that tier.
33
33
 
34
+ ### Credentials per environment
35
+
36
+ Dhan treats sandbox and production as separate environments with separate credentials
37
+ ("Do not reuse production tokens for sandbox requests or sandbox tokens for production
38
+ requests" — docs.dhanhq.co, *Sandbox*). The gem therefore resolves credentials per tier
39
+ alongside the host:
40
+
41
+ | Request routed to | `access-token` header | `client-id` header / body `dhanClientId` |
42
+ | ----------------- | --------------------- | ---------------------------------------- |
43
+ | production | `access_token` (`DHAN_ACCESS_TOKEN`) | `client_id` (`DHAN_CLIENT_ID`) |
44
+ | sandbox | `sandbox_access_token` (`DHAN_SANDBOX_ACCESS_TOKEN`) | `sandbox_client_id` (`DHAN_SANDBOX_CLIENT_ID`) |
45
+
46
+ A body `dhanClientId` equal to the production `client_id` (models fill it in) is rewritten
47
+ to the sandbox ID on sandbox-bound requests; an explicitly different ID is left alone. In
48
+ `:sandbox` mode the sandbox pair falls back to the primary pair; in `:hybrid` (or with a
49
+ tier overridden to the sandbox) missing sandbox credentials raise
50
+ `DhanHQ::AuthenticationError` before any I/O.
51
+
34
52
  ## Sandbox behavior
35
53
 
36
54
  - **Sandbox does NOT execute real order fills/matching.** Confirmed both via Dhan's own docs ("the sandbox behaves like the live API but does not execute real orders") and empirically: a `MARKET` order placed on sandbox validates and returns a real `orderId`, but stays at `PENDING` indefinitely — it never transitions to `TRADED`, even after 24+ seconds of polling. Use sandbox to validate request/response plumbing (auth, payload shape, error codes), not to test fill, cancel, or square-off behavior.
@@ -44,37 +62,22 @@ request) → `environment_overrides[tier]` → mode default for that tier.
44
62
 
45
63
  ---
46
64
 
47
- ## Sandbox: verified vs not working / unverified
48
-
49
- | Status | Endpoints |
50
- |-----------|-----------|
51
- | **Verified on sandbox** (gem specs) | `GET /v2/profile`, `GET /v2/fundlimit` |
52
- | **Sandbox connectivity spec** | `spec/dhan_hq/sandbox_connectivity_spec.rb` — uses VCR `record: :new_episodes`. In CI, use committed cassettes or skip without sandbox credentials; locally, run with `VCR_RECORD=new_episodes` and sandbox credentials to record. |
53
- | **Not supported in sandbox** | All WebSocket endpoints (order updates, market feed, market depth). Use production only. |
54
- | **Not verified on sandbox** | All other REST endpoints below. They may fail, return differently, or be unavailable in sandbox. Use Dhan documentation or manual testing before relying on them in sandbox. |
55
-
56
- **REST endpoints not verified / may not work on sandbox** (only profile and funds are verified):
57
-
58
- - `/v2/ledger`, `/v2/trades/{from}/{to}/{page}` (statements)
59
- - `/v2/orders` (all order CRUD, slicing, external)
60
- - `/v2/positions`, `/v2/positions/convert`
61
- - `/v2/holdings`
62
- - `/v2/trades`, `/v2/trades/{order_id}`
63
- - `/v2/forever/orders` (all)
64
- - `/v2/super/orders` (all)
65
- - `/v2/killswitch`
66
- - `/trader-control`
67
- - `/ip/getIP`, `/ip/setIP`, `/ip/modifyIP`
68
- - `/edis/tpin`, `/edis/form`, `/edis/bulkform`, `/edis/inquire/{isin}`
69
- - `/alerts/orders`
70
- - `/v2/pnlExit`
71
- - `/v2/margincalculator`, `/v2/margincalculator/multi`
72
- - `/v2/instrument/{segment}`
73
- - `/v2/marketfeed/ltp`, `/v2/marketfeed/ohlc`, `/v2/marketfeed/quote`
74
- - `/v2/optionchain`, `/v2/optionchain/expirylist`
75
- - `/v2/charts/historical`, `/v2/charts/intraday`, `/v2/charts/rollingoption`
76
- - `/v2/data/companyinfo`, `/v2/data/marketmovers`, `/v2/data/newsheadline`, `/v2/data/technical` (ScanX)
77
- - `/v2/globalstocks/*` (Global Stocks — assumed unavailable on sandbox; always production in `:hybrid`)
65
+ ## Sandbox endpoint support
66
+
67
+ Source: the sandbox's own OpenAPI document (`https://sandbox.dhan.co/v2/v3/api-docs`), the
68
+ *Sandbox* page on docs.dhanhq.co, and an unauthenticated probe of each path (an existing
69
+ route answers 401/400, a missing one 404).
70
+
71
+ | Status | Endpoints |
72
+ |--------|-----------|
73
+ | **Served by the sandbox** | `/orders` (place, modify, cancel, book, by id, by correlation id), `/orders/slicing`, `/trades`, `/trades/{order-id}`, `/holdings`, `/positions`, `/positions/convert`, `POST /killswitch`, `/edis/tpin`, `/edis/form`, `/edis/bulkform`, `/edis/inquire/{isin}`, `/fundlimit`, `POST /margincalculator`, `/ledger`, `/trades/{from}/{to}/{page}`, `/charts/historical`, `/charts/intraday`, `/forever/orders` (in the sandbox OpenAPI document, not on the docs page), `/profile` (route exists; not documented) |
74
+ | **Not served — 404** (gem raises `DhanHQ::SandboxUnsupportedError`) | `/super/orders`, `/alerts/orders`, `/alerts/multi/orders`, `/pnlExit`, `/margincalculator/multi`, `/ip/*`, `/marketfeed/*`, `/optionchain`, `/optionchain/expirylist`, `/charts/rollingoption`, `/data/*` (ScanX), `/globalstocks/*` |
75
+ | **Unclear** | `GET /killswitch` and `DELETE /positions` answer 400 rather than 404 but are absent from both sandbox sources. Iceberg/TWAP (`/v2/orders/iceberg`, `/v2/orders/twap`) are not in any Dhan documentation; the gateway answers any `/v2/orders/*` path, so a probe cannot tell. |
76
+ | **Not supported** | All WebSocket endpoints (order updates, market feed, market depth). Production only. |
77
+
78
+ The deny-list lives in `DhanHQ::Constants::SANDBOX_UNSUPPORTED_PATH_PREFIXES`. In
79
+ `:hybrid` mode a blocked request is **never** redirected to production — that would turn a
80
+ paper order into a real one.
78
81
 
79
82
  ---
80
83
 
@@ -118,7 +121,7 @@ Paths are as built by the gem (HTTP_PATH + endpoint). Base URL is resolved per t
118
121
  | Purpose | URL / path | Method |
119
122
  |-------------------|--------------------------------|--------|
120
123
  | Generate token | `https://auth.dhan.co/app/generateAccessToken` | POST |
121
- | Renew token | `https://api.dhan.co/v2/RenewToken` | POST |
124
+ | Renew token | `https://api.dhan.co/v2/RenewToken` | GET |
122
125
 
123
126
  These are **not** mode-aware; they always use the URLs above.
124
127
 
data/lib/DhanHQ/auth.rb CHANGED
@@ -38,18 +38,15 @@ module DhanHQ
38
38
  # @param pin [String] Your 6-digit Dhan PIN
39
39
  # @param totp [String] 6-digit TOTP code (or use generate_totp helper)
40
40
  # @return [Hash] Response hash with accessToken, expiryTime, etc.
41
- # @raise [DhanHQ::InvalidAuthenticationError] On authentication failure
41
+ # @raise [DhanHQ::InvalidAuthenticationError] When Dhan rejects the credentials
42
+ # @raise [DhanHQ::RateLimitError, DhanHQ::InternalServerError, DhanHQ::NetworkError] On retryable failures
42
43
  def self.generate_access_token(dhan_client_id:, pin:, totp:)
43
- conn = build_connection(AUTH_BASE_URL)
44
-
45
- response = conn.post("/app/generateAccessToken") do |req|
46
- req.headers["Accept"] = "application/json"
47
-
48
- req.params = {
49
- dhanClientId: dhan_client_id,
50
- pin: pin,
51
- totp: totp
52
- }
44
+ response = perform("GenerateAccessToken") do
45
+ # Relative path: a leading slash would make Faraday discard the base URL's path.
46
+ build_connection(AUTH_BASE_URL).post("app/generateAccessToken") do |req|
47
+ req.headers["Accept"] = "application/json"
48
+ req.params = { dhanClientId: dhan_client_id, pin: pin, totp: totp }
49
+ end
53
50
  end
54
51
 
55
52
  handle_response(response, context: "GenerateAccessToken")
@@ -57,22 +54,26 @@ module DhanHQ
57
54
 
58
55
  # Renews a web-generated access token (24h validity).
59
56
  #
60
- # POST https://api.dhan.co/v2/RenewToken
57
+ # GET https://api.dhan.co/v2/RenewToken
61
58
  #
62
- # ⚠️ Works ONLY for tokens generated from Dhan Web dashboard.
59
+ # ⚠️ Works ONLY for tokens generated from Dhan Web dashboard, and only while
60
+ # the token is still active; renewing an expired token returns an error.
63
61
  # For TOTP-generated tokens, regenerate instead of renewing.
64
62
  #
65
63
  # @param access_token [String] Current JWT access token
66
64
  # @param client_id [String] Dhan client ID (dhanClientId)
67
65
  # @return [Hash] Response hash with new accessToken and expiryTime
68
- # @raise [DhanHQ::InvalidAuthenticationError] On renewal failure
66
+ # @raise [DhanHQ::InvalidAuthenticationError] When Dhan rejects the token
67
+ # @raise [DhanHQ::RateLimitError, DhanHQ::InternalServerError, DhanHQ::NetworkError] On retryable failures
69
68
  def self.renew_token(access_token:, client_id:)
70
- conn = build_connection(API_BASE_URL)
71
-
72
- response = conn.post("/RenewToken") do |req|
73
- req.headers["access-token"] = access_token
74
- req.headers["dhanClientId"] = client_id
75
- req.headers["Accept"] = "application/json"
69
+ response = perform("RenewToken") do
70
+ # Relative path keeps the /v2 base. GET, per Dhan's documented curl
71
+ # example and the official Python SDK.
72
+ build_connection(API_BASE_URL).get("RenewToken") do |req|
73
+ req.headers["access-token"] = access_token
74
+ req.headers["dhanClientId"] = client_id
75
+ req.headers["Accept"] = "application/json"
76
+ end
76
77
  end
77
78
 
78
79
  handle_response(response, context: "RenewToken")
@@ -86,27 +87,55 @@ module DhanHQ
86
87
  ROTP::TOTP.new(secret).now
87
88
  end
88
89
 
89
- private
90
-
90
+ # Bounded timeouts, same defaults as Client#build_connection: a stalled auth
91
+ # endpoint must not hang the caller.
91
92
  def self.build_connection(base_url)
92
93
  Faraday.new(url: base_url) do |c|
93
94
  c.request :url_encoded
95
+ c.options.timeout = ENV.fetch("DHAN_READ_TIMEOUT", 30).to_i
96
+ c.options.open_timeout = ENV.fetch("DHAN_CONNECT_TIMEOUT", 10).to_i
97
+ c.options.write_timeout = ENV.fetch("DHAN_WRITE_TIMEOUT", 30).to_i
94
98
  c.adapter Faraday.default_adapter
95
99
  end
96
100
  end
97
101
  private_class_method :build_connection
98
102
 
103
+ def self.perform(context)
104
+ yield
105
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
106
+ raise DhanHQ::NetworkError, "#{context} request failed: #{e.class}: #{e.message}"
107
+ end
108
+ private_class_method :perform
109
+
110
+ # Rate limits and server errors are retryable; everything else means the
111
+ # credentials themselves were refused.
99
112
  def self.handle_response(response, context:)
100
- unless response.success?
101
- body = response.body.is_a?(Hash) ? response.body : {}
102
- error_message = body["errorMessage"] || body["message"] || response.body.to_s
113
+ body = parse_body(response.body)
114
+ return body if response.success?
103
115
 
104
- raise DhanHQ::InvalidAuthenticationError,
105
- "#{context} failed: #{response.status} #{error_message}"
116
+ message = body["errorMessage"] || body["message"] || response.body.to_s
117
+ raise error_class_for(response.status).new("#{context} failed: #{response.status} #{message}".strip, response_body: body)
118
+ end
119
+ private_class_method :handle_response
120
+
121
+ def self.error_class_for(status)
122
+ case status
123
+ when 429 then DhanHQ::RateLimitError
124
+ when 500..599 then DhanHQ::InternalServerError
125
+ else DhanHQ::InvalidAuthenticationError
106
126
  end
127
+ end
128
+ private_class_method :error_class_for
129
+
130
+ # Dhan does not always label responses as JSON, so parse whatever arrives.
131
+ def self.parse_body(body)
132
+ return body if body.is_a?(Hash)
133
+ return {} if body.to_s.strip.empty?
107
134
 
108
- response.body.is_a?(Hash) ? response.body : {}
135
+ JSON.parse(body.to_s)
136
+ rescue JSON::ParserError
137
+ {}
109
138
  end
110
- private_class_method :handle_response
139
+ private_class_method :parse_body
111
140
  end
112
141
  end
data/lib/DhanHQ/client.rb CHANGED
@@ -130,6 +130,8 @@ module DhanHQ
130
130
  payload = with_correlation_id(method, path, payload)
131
131
  return dry_run.response_for(method, path, payload) if dry_run.simulates?(method, path)
132
132
 
133
+ ensure_sandbox_supports!(method, path)
134
+
133
135
  @token_manager&.ensure_valid_token!
134
136
  @rate_limiter.throttle!
135
137
 
@@ -270,6 +272,29 @@ module DhanHQ
270
272
 
271
273
  private
272
274
 
275
+ # Credentials for this client's API tier: the sandbox pair when the tier
276
+ # routes to the sandbox host, the production pair otherwise.
277
+ def request_access_token
278
+ @config.access_token_for(@api_type)
279
+ end
280
+
281
+ def request_client_id
282
+ @config.client_id_for(@api_type)
283
+ end
284
+
285
+ # Fails fast when this request would hit the sandbox host on a path it does
286
+ # not serve (see {Constants::SANDBOX_UNSUPPORTED_PATH_PREFIXES}), instead of
287
+ # surfacing an opaque 404. Never falls back to production.
288
+ def ensure_sandbox_supports!(method, path)
289
+ return unless environment == :sandbox
290
+ return unless Constants::SANDBOX_UNSUPPORTED_PATH_PREFIXES.any? { |prefix| path.start_with?(prefix) }
291
+
292
+ raise DhanHQ::SandboxUnsupportedError,
293
+ "#{method.to_s.upcase} #{path} is not available on the Dhan sandbox (#{@config.mode} mode routes " \
294
+ "#{@api_type} to it). For a read-only endpoint, route its tier to production with " \
295
+ "environment_overrides (e.g. { #{@api_type}: :live }); order-placing endpoints have no sandbox equivalent."
296
+ end
297
+
273
298
  def resolve_config(config_or_attrs, keyword_config, attrs = {})
274
299
  if config_or_attrs.is_a?(DhanHQ::Configuration)
275
300
  fork_config_with_attrs(config_or_attrs, attrs)
@@ -84,7 +84,7 @@ module DhanHQ
84
84
 
85
85
  DhanHQ::Risk::Pipeline.run!(
86
86
  instrument: instrument,
87
- args: params,
87
+ args: risk_args(params),
88
88
  type: trade_type_for(exchange_segment),
89
89
  now: Time.now
90
90
  )
@@ -118,6 +118,18 @@ module DhanHQ
118
118
  )
119
119
  end
120
120
 
121
+ # The risk checks read snake_case String keys ("quantity", "order_type", ...).
122
+ # Order params arrive as camelCase Strings from the models and as Symbols or
123
+ # Strings from direct resource callers; without this, every check that reads a
124
+ # multi-word key silently saw nil, and Symbol-keyed callers failed
125
+ # "Quantity must be > 0" on a valid quantity.
126
+ #
127
+ # @param params [Hash]
128
+ # @return [Hash{String => Object}]
129
+ def risk_args(params)
130
+ (params || {}).to_h.transform_keys { |key| key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase }
131
+ end
132
+
121
133
  # Maps an exchange segment string to a pipeline trade type.
122
134
  def trade_type_for(exchange_segment)
123
135
  case exchange_segment.to_s
@@ -41,6 +41,25 @@ module DhanHQ
41
41
  # @return [String, nil] The access token or `nil` if not set.
42
42
  attr_accessor :access_token
43
43
 
44
+ # Client ID issued for the Dhan sandbox (generated on Dhan Cloud, cloud.dhanhq.co).
45
+ #
46
+ # Dhan runs sandbox and production as separate environments with separate
47
+ # credentials: a production token is rejected by sandbox.dhan.co and vice
48
+ # versa. Every request routed to the sandbox host (see {#environment_for})
49
+ # authenticates with these credentials. In +:sandbox+ mode they fall back to
50
+ # {#client_id}/{#access_token} when unset, so existing full-sandbox setups
51
+ # keep working; in +:hybrid+ mode they are required.
52
+ #
53
+ # Set via +DHAN_SANDBOX_CLIENT_ID+ or in {DhanHQ.configure}.
54
+ # @return [String, nil]
55
+ attr_accessor :sandbox_client_id
56
+
57
+ # Access token issued for the Dhan sandbox. See {#sandbox_client_id}.
58
+ #
59
+ # Set via +DHAN_SANDBOX_ACCESS_TOKEN+ or in {DhanHQ.configure}.
60
+ # @return [String, nil]
61
+ attr_accessor :sandbox_access_token
62
+
44
63
  # Optional callable (Proc/lambda) that returns the access token at request time.
45
64
  # When set, {#resolved_access_token} calls this instead of using {#access_token}.
46
65
  # @return [Proc, nil]
@@ -286,6 +305,48 @@ module DhanHQ
286
305
  mode == :sandbox
287
306
  end
288
307
 
308
+ # Client ID to authenticate requests on the given API tier with.
309
+ #
310
+ # Sandbox-bound tiers use {#sandbox_client_id}; production-bound tiers use
311
+ # {#client_id}.
312
+ #
313
+ # @param api_type [Symbol, nil]
314
+ # @return [String, nil]
315
+ # @raise [DhanHQ::AuthenticationError] When a sandbox-bound tier has no
316
+ # sandbox credentials outside +:sandbox+ mode.
317
+ def client_id_for(api_type)
318
+ return client_id unless environment_for(api_type) == :sandbox
319
+
320
+ sandbox_credential(sandbox_client_id, "sandbox_client_id", "DHAN_SANDBOX_CLIENT_ID", api_type) { client_id }
321
+ end
322
+
323
+ # Access token to authenticate requests on the given API tier with.
324
+ # See {#client_id_for}.
325
+ #
326
+ # @param api_type [Symbol, nil]
327
+ # @return [String, nil]
328
+ # @raise [DhanHQ::AuthenticationError]
329
+ def access_token_for(api_type)
330
+ return resolved_access_token unless environment_for(api_type) == :sandbox
331
+
332
+ sandbox_credential(sandbox_access_token, "sandbox_access_token", "DHAN_SANDBOX_ACCESS_TOKEN", api_type) do
333
+ resolved_access_token
334
+ end
335
+ end
336
+
337
+ # Thresholds for the pre-trade risk checks, keyed by {Risk::Limits::NAMES}
338
+ # (+max_quantity+, +max_notional+, +max_open_positions+, +daily_max_loss+,
339
+ # +max_concentration_pct+). Unset limits keep each check's built-in default.
340
+ # Also settable through +DHAN_RISK_*+ variables (see {Risk::Limits::ENV_KEYS}).
341
+ # @return [Hash{Symbol => Numeric}]
342
+ attr_reader :risk_limits
343
+
344
+ # @param value [Hash, nil]
345
+ # @raise [ArgumentError] On an unknown limit or a non-positive value.
346
+ def risk_limits=(value)
347
+ @risk_limits = Risk::Limits.coerce(value)
348
+ end
349
+
289
350
  # True when {#mode} is +:hybrid+ — live market data, sandbox execution.
290
351
  # @return [Boolean]
291
352
  def hybrid?
@@ -320,6 +381,8 @@ module DhanHQ
320
381
  def initialize(attrs = {})
321
382
  @client_id = attrs[:client_id] || ENV.fetch("DHAN_CLIENT_ID", nil)
322
383
  @access_token = attrs[:access_token] || ENV.fetch("DHAN_ACCESS_TOKEN", nil)
384
+ @sandbox_client_id = attrs[:sandbox_client_id] || ENV.fetch("DHAN_SANDBOX_CLIENT_ID", nil)
385
+ @sandbox_access_token = attrs[:sandbox_access_token] || ENV.fetch("DHAN_SANDBOX_ACCESS_TOKEN", nil)
323
386
  @access_token_provider = attrs[:access_token_provider]
324
387
  @on_token_expired = attrs[:on_token_expired]
325
388
  @sandbox = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
@@ -337,6 +400,7 @@ module DhanHQ
337
400
  attrs[:environment_overrides] || ENV.fetch("DHAN_ENV_OVERRIDES", nil)
338
401
  )
339
402
  @mode = resolve_mode(attrs)
403
+ @risk_limits = attrs.key?(:risk_limits) ? Risk::Limits.coerce(attrs[:risk_limits]) : Risk::Limits.from_env
340
404
  @ws_version = (attrs[:ws_version] || ENV.fetch("DHAN_WS_VERSION", 2)).to_i
341
405
  @ws_order_url = attrs[:ws_order_url] || ENV.fetch("DHAN_WS_ORDER_URL", nil)
342
406
  @ws_market_feed_url = attrs[:ws_market_feed_url] || ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
@@ -351,6 +415,21 @@ module DhanHQ
351
415
 
352
416
  private
353
417
 
418
+ # Returns a sandbox credential, falling back to the production one only in
419
+ # full +:sandbox+ mode (where the "production" fields have always held the
420
+ # sandbox credentials). Anywhere else — +:hybrid+, or a tier overridden to
421
+ # the sandbox — sending a production credential to the sandbox host is a
422
+ # guaranteed DH-901, so it fails fast with an actionable message instead.
423
+ def sandbox_credential(value, attr_name, env_name, api_type)
424
+ return value.to_s unless value.to_s.strip.empty?
425
+ return yield if mode == :sandbox
426
+
427
+ raise DhanHQ::AuthenticationError,
428
+ "#{attr_name} is required: #{api_type || "this"} requests route to the Dhan sandbox in " \
429
+ "#{mode} mode, and the sandbox rejects production credentials. " \
430
+ "Set #{env_name} (generate it on https://cloud.dhanhq.co)."
431
+ end
432
+
354
433
  # Resolves the initial mode from attributes and ENV.
355
434
  #
356
435
  # Precedence: an explicit +:mode+ attribute wins, then +DHAN_MODE+, then the
@@ -88,8 +88,12 @@ module DhanHQ
88
88
  TRADED = "TRADED"
89
89
  EXPIRED = "EXPIRED"
90
90
  MODIFIED = "MODIFIED"
91
+ # Listed in the orderStatus response enum of the order, super order and
92
+ # slicing endpoints (docs.dhanhq.co).
93
+ INACTIVE = "INACTIVE"
91
94
 
92
- ALL = [TRANSIT, PENDING, CLOSED, TRIGGERED, REJECTED, CANCELLED, PART_TRADED, TRADED, EXPIRED, MODIFIED].freeze
95
+ ALL = [TRANSIT, PENDING, CLOSED, TRIGGERED, REJECTED, CANCELLED, PART_TRADED, TRADED, EXPIRED, MODIFIED,
96
+ INACTIVE].freeze
93
97
  end
94
98
 
95
99
  # AMO (after market order) timing options.
@@ -597,6 +601,28 @@ module DhanHQ
597
601
  "/v2/data"
598
602
  ].freeze
599
603
 
604
+ # Path prefixes the Dhan sandbox (https://sandbox.dhan.co/v2) does not serve.
605
+ #
606
+ # Derived from the sandbox's own OpenAPI document (sandbox.dhan.co/v2/v3/api-docs)
607
+ # and the "Sandbox" page of docs.dhanhq.co, and confirmed by each prefix
608
+ # answering HTTP 404 on the sandbox host. Requests routed to the sandbox for
609
+ # these paths fail fast with {DhanHQ::SandboxUnsupportedError}.
610
+ #
611
+ # Deliberately a deny-list of verified gaps rather than an allow-list: a
612
+ # sandbox route Dhan adds later keeps working without a gem release.
613
+ SANDBOX_UNSUPPORTED_PATH_PREFIXES = %w[
614
+ /v2/marketfeed/
615
+ /v2/optionchain
616
+ /v2/charts/rollingoption
617
+ /v2/data/
618
+ /v2/super/orders
619
+ /v2/alerts/
620
+ /v2/pnlExit
621
+ /v2/margincalculator/multi
622
+ /v2/ip/
623
+ /v2/globalstocks/
624
+ ].freeze
625
+
600
626
  # Path prefixes for which the request body (POST/PUT/PATCH) must include dhanClientId.
601
627
  # Injection is done in the client layer when building the payload.
602
628
  PAYLOAD_REQUIRES_DHAN_CLIENT_ID_PREFIXES = %w[
data/lib/DhanHQ/errors.rb CHANGED
@@ -48,6 +48,12 @@ module DhanHQ
48
48
  # Raised when the 25-modifications-per-order API cap would be exceeded.
49
49
  # Count is tracked per Order instance in this process only (see Order#modify).
50
50
  class ModificationLimitError < Error; end
51
+ # Raised before any network I/O when a request would be routed to the Dhan
52
+ # sandbox host for an endpoint the sandbox does not serve (super orders,
53
+ # conditional orders, P&L exit, market quotes, option chain, ...). The request
54
+ # is never redirected to production instead: in +:hybrid+ mode that would turn
55
+ # a paper order into a real one.
56
+ class SandboxUnsupportedError < Error; end
51
57
  # Raised when an order placement is attempted but ENV["LIVE_TRADING"] != "true".
52
58
  class LiveTradingDisabledError < Error; end
53
59
  # Raised when the API signals an issue with the requested data payload.