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 +4 -4
- data/CHANGELOG.md +47 -0
- data/GUIDE.md +3 -0
- data/README.md +17 -3
- data/docs/AUTHENTICATION.md +13 -2
- data/docs/CONFIGURATION.md +7 -0
- data/docs/ENDPOINTS_AND_SANDBOX.md +35 -32
- data/lib/DhanHQ/auth.rb +58 -29
- data/lib/DhanHQ/client.rb +25 -0
- data/lib/DhanHQ/concerns/order_audit.rb +13 -1
- data/lib/DhanHQ/configuration.rb +79 -0
- data/lib/DhanHQ/constants.rb +27 -1
- data/lib/DhanHQ/errors.rb +6 -0
- data/lib/DhanHQ/helpers/request_helper.rb +35 -6
- data/lib/DhanHQ/helpers/response_helper.rb +1 -1
- data/lib/DhanHQ/models/iceberg_order.rb +1 -1
- data/lib/DhanHQ/models/super_order.rb +1 -1
- data/lib/DhanHQ/models/twap_order.rb +1 -1
- data/lib/DhanHQ/resources/instruments.rb +30 -0
- data/lib/DhanHQ/resources/margin_calculator.rb +4 -1
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +4 -1
- data/lib/DhanHQ/risk/checks/concentration.rb +80 -11
- data/lib/DhanHQ/risk/checks/max_loss.rb +11 -6
- data/lib/DhanHQ/risk/checks/options.rb +4 -1
- data/lib/DhanHQ/risk/checks/order_type.rb +3 -1
- data/lib/DhanHQ/risk/checks/position_limits.rb +7 -4
- data/lib/DhanHQ/risk/checks/quantity.rb +6 -3
- data/lib/DhanHQ/risk/exposure.rb +48 -0
- data/lib/DhanHQ/risk/limits.rb +75 -0
- data/lib/DhanHQ/risk/pipeline.rb +17 -9
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/market_depth/client.rb +83 -24
- data/lib/DhanHQ/ws/market_depth/decoder.rb +109 -102
- data/lib/dhan_hq.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2929a9a1a182987ee1a71b822d28db5879919348e6598a545d2133d86721d82d
|
|
4
|
+
data.tar.gz: afc1857745b80f145d5f2aa0e6f3445ec5c5c0adbc43de58bfddda58f1e8f33f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
268
|
-
config.access_token
|
|
269
|
-
config.
|
|
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
|
data/docs/AUTHENTICATION.md
CHANGED
|
@@ -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 (
|
|
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)
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
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` |
|
|
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]
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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]
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
135
|
+
JSON.parse(body.to_s)
|
|
136
|
+
rescue JSON::ParserError
|
|
137
|
+
{}
|
|
109
138
|
end
|
|
110
|
-
private_class_method :
|
|
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
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -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
|
data/lib/DhanHQ/constants.rb
CHANGED
|
@@ -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
|
|
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.
|