DhanHQ 4.0.0 → 4.1.0

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: 8c2f0784210b7f48ccb056b8cc486ce5c3ef655f69a4bd2834a150b1d04a8bc7
4
- data.tar.gz: a6628fc83e8ec61bd4ca4b0eb6147a67232156f98ab49196c0a81850f9b4ac59
3
+ metadata.gz: d79f2577f59f6c04874eb44e4d318e5584e711e205be25f6fa000053ec772846
4
+ data.tar.gz: dca8144795ab2b77700a3947512c04e73c8edcbaa287b74adb746e671158e583
5
5
  SHA512:
6
- metadata.gz: d0a4147017f7722d9a74cec3105fb57e9b8710142abda6c04d5948caa7500b21ce8c31958cc501e99643a31afffe5e64846b5a73ff239f1a8fb87074e57a6856
7
- data.tar.gz: 508f55621273349e9060e941cc9bf8b7cef7fb28befb8026b57b977ba0954760f783eb5dbb26c19593ac113232c12fa74884fcd57a81b61dd21dbc3d66c7b9e9
6
+ metadata.gz: e5a38d2663377da284188dc32fd0e796394ee899a34b61d5f82b5a487b0a47477a9e6bc1c0fdfc0b7fa93959d8673e257a83b7eea1af1a48885526ecc506a89f
7
+ data.tar.gz: 3f2023c47fc5e5b1f6fcce29f704c2c4ac03ad887a91dbc0c33bba201a151e8f03793bdc736915a295e98a5e661810e6229e32b37ea11c09b977fbcbb1b1c5d3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## [4.1.0] - 2026-10-10
2
+
3
+ ### Fixed (full-gem review pass — `ruby-agent-skills` audit)
4
+
5
+ - **WebSocket feed request codes now match the official Annexure table** (`dhanhq.co/docs/v2/annexure#feed-request-code`). Market feed subscribe per mode: 15/17/21 (was 15 for all three); unsubscribe: 16/18/22 (was 12 for all — 12 is the whole-connection *Disconnect Feed* frame, so per-instrument unsubscribes most plausibly tore down the entire socket). Market depth unsubscribe: 24 (was 12; subscribe stays 23). New spec `spec/dhan_hq/ws/connection_spec.rb` asserts the mapping. **Caveat:** verified against the published table only — `wss://api-feed.dhan.co` is not reachable from the CI environment (geo-block outside India), so a one-constant revert is documented in the review notes if live testing disagrees.
6
+ - **Error contract consistency.** `Models::Order#modify/#cancel/#refresh/#slice_order` and `WS::Client` initialization now raise typed errors (`DhanHQ::OrderError`, `DhanHQ::AuthenticationError`) instead of bare `RuntimeError`s, so callers rescuing `DhanHQ::Error` no longer miss guard-clause failures.
7
+ - **`RateLimiter` cleanup threads** now spawn only for intervals with finite limits (was 3 immortal threads per tier — 18 in a typical process; now 0 for quote/non-trading, 1 for order/data/global, 3 for option chain). The misleading "matches the published rate-limit table" comment was corrected: values follow this repo's own documented operational limits; per-minute platform caps remain an open question (table unreachable — broken docs site).
8
+ - **`WS::Registry`** is now mutex-synchronized (register/unregister/stop_all were unsynchronized) and logs swallowed `stop` failures at debug level instead of rescuing silently.
9
+ - **`BaseModel` attribute accessors** are defined once per class instead of two singleton methods per attribute per instance (allocation-heavy on collections). Parameter key style dispatches through `BaseModel.param_key_style` (:title for OptionChain) instead of the fragile `class.name.include?("OptionChain")` check. `validate_attributes` no longer crashes when no contract is declared.
10
+ - **Timeouts on the token-endpoint fetch** (`configure_from_token_endpoint` / `configure_from_dashboard`): bounded open/read timeouts matching `Client#build_connection`; previously the only unbounded HTTP path in the gem.
11
+ - **Dead code removed:** `BaseAPI#request` (unused private), the commented-out `format_params`/`optionchain_api?` block in `AttributeHelper`, the commented `validate_params!` line in `BaseModel.create`, and the redundant `class Error` reopen in `lib/dhan_hq.rb` (already fully defined in `errors.rb`).
12
+ - **Docs:** `docs/ENDPOINTS_AND_SANDBOX.md` no longer documents the non-existent `bin/call_all_endpoints.rb`; the smoke-testing section now references `bin/console`, the sandbox connectivity spec, and `bin/ws_feed_test.rb`. The `DHAN_DEBUG` `puts` in `RateLimiter` was replaced with `DhanHQ.logger&.debug`.
13
+
14
+ ### Added
15
+
16
+ - **Environment modes — `config.mode = :live | :sandbox | :hybrid` (`DHAN_MODE`)** — one switch that decides which Dhan host every request hits. `:live` (default) keeps today's behaviour; `:sandbox` replaces the old all-or-nothing `sandbox = true` flag; and the new **`:hybrid`** mode is the strategy-rehearsal combination the docs site describes — **live market data + sandbox execution**: market feed, option chain, historical charts, instruments and ScanX keep hitting `https://api.dhan.co/v2` for real prices while orders, positions, funds, statements and the rest of the trading surface hit `https://sandbox.dhan.co/v2`, where orders are validated but never fill.
17
+
18
+ ```ruby
19
+ DhanHQ.configure do |config|
20
+ config.mode = :hybrid # or DHAN_MODE=hybrid
21
+ end
22
+
23
+ client.market_feed.ltp("NSE_EQ" => [11536]) # -> api.dhan.co (live prices)
24
+ client.orders.create(...) # -> sandbox.dhan.co (paper execution)
25
+ ```
26
+
27
+ - Resolution is per API tier (`order_api`, `data_api`, `quote_api`, `option_chain`, `non_trading_api`, `global_stocks_api`) via `Configuration#base_url_for(api_type)` / `#environment_for(api_type)`; the client rebuilds its Faraday connection when the resolved URL changes, so a mid-flight mode switch takes effect.
28
+ - `config.environment_overrides = { non_trading_api: :live }` (or `DHAN_ENV_OVERRIDES="non_trading_api=live"`) overrides any tier on top of the mode — e.g. keep real fund limits while everything else runs on the sandbox.
29
+ - `sandbox = true` / `DHAN_SANDBOX=true` remain supported aliases for `mode = :sandbox`; `mode` takes precedence when both are given. Unknown modes raise `ArgumentError` at configuration time, not on the first request.
30
+ - Per-client override: `DhanHQ::Client.new(config: shared, mode: :hybrid)` **forks** the configuration rather than mutating the shared/global one, so two clients can run different modes side by side.
31
+ - **Global Stocks (US equities) stays on production in `:hybrid`** on its own `global_stocks_api` rate-limit tier (same limits as order APIs) — `sandbox.dhan.co` does not mirror the `/v2/globalstocks/*` surface.
32
+ - **Sandbox-aware LIVE_TRADING gate** — the `ENV["LIVE_TRADING"]` guard now applies only to writes whose effective target is production. Any write routed to the sandbox host (full `:sandbox`, or the trading tiers under `:hybrid`) skips the gate automatically, because no real money can move there. Every order-audit log line now also records `"target":"live"|"sandbox"`, so the audit trail shows which host each attempt fired at.
33
+ - **ScanX data API — `client.scanx`** — wraps the four `/v2/data/*` endpoints from the official API surface, each validated by a dry-validation contract, riding the `data_api` tier (live in `:hybrid`):
34
+ - `#company_info` — `POST /v2/data/companyinfo`: company overview, valuation/profitability ratios and shareholding pattern (`metrics: %w[CO RATIOS SHP]`).
35
+ - `#market_movers` — `POST /v2/data/marketmovers`: top options/futures/stocks by OI, volume or price movement; the contract enforces the API's own rule that `expiry` is required for derivative instruments and `universe` for EQUITY.
36
+ - `#news_headlines` — `POST /v2/data/newsheadline`: live news by category/stock (limit 1–50); `dhanClientId` is injected automatically.
37
+ - `#technical_data` — `POST /v2/data/technical`: 24 server-side indicators (SMA/EMA families, RSI_14, MACD_HIST, STOCH, ATR_14, ADX_14, pivots…) computed on the latest closed candle.
38
+ - `/v2/data` joins the `client-id`-header prefixes so ScanX requests authenticate like the other data APIs.
39
+
1
40
  ## [4.0.0] - 2026-08-31
2
41
 
3
42
  ### Added
data/GUIDE.md CHANGED
@@ -57,6 +57,8 @@ Set any of the following environment variables _before_ calling
57
57
 
58
58
  | Variable | Purpose |
59
59
  | ----------------------------------------- | ---------------------------------------------------- |
60
+ | `DHAN_MODE` | `live` (default), `sandbox`, or `hybrid` — see [Environments](#environments-live-sandbox-hybrid) below. |
61
+ | `DHAN_ENV_OVERRIDES` | String-form per-tier overrides, e.g. `non_trading_api=live`. |
60
62
  | `DHAN_LOG_LEVEL` | Change logger verbosity (`INFO` default). |
61
63
  | `DHAN_BASE_URL` | Override the REST API host. |
62
64
  | `DHAN_WS_VERSION` | Target a specific WebSocket API version. |
@@ -93,6 +95,28 @@ orders1 = client1.orders.all
93
95
  orders2 = client2.orders.all
94
96
  ```
95
97
 
98
+ **Environments: live / sandbox / hybrid**
99
+
100
+ `config.mode` picks which host each tier hits. `:hybrid` is the strategy-rehearsal mode —
101
+ live market data from `api.dhan.co`, paper execution on `sandbox.dhan.co`:
102
+
103
+ ```ruby
104
+ DhanHQ.configure do |config|
105
+ config.mode = :hybrid # or DHAN_MODE=hybrid
106
+ # Optional: per-tier escape hatch on top of the mode
107
+ # config.environment_overrides = { non_trading_api: :live }
108
+ end
109
+
110
+ client.market_feed.ltp("NSE_EQ" => [11536]) # live prices from api.dhan.co
111
+ client.orders.create(...) # paper execution on sandbox.dhan.co
112
+ ```
113
+
114
+ The `LIVE_TRADING` gate is relaxed automatically for sandbox-targeted writes; production
115
+ writes still require `ENV["LIVE_TRADING"]="true"`. Global Stocks always stays on production
116
+ in `:hybrid`, and WebSocket streams are production-only (the Dhan sandbox has no WS).
117
+ See [docs/ENDPOINTS_AND_SANDBOX.md](docs/ENDPOINTS_AND_SANDBOX.md) for the full routing
118
+ and verification table.
119
+
96
120
  ---
97
121
 
98
122
  ## Working With Models
data/README.md CHANGED
@@ -248,6 +248,76 @@ ws_orders = client1.ws_order_update
248
248
 
249
249
  > **Full details**: TOTP flows, partner mode, token endpoint bootstrap, auto-management — see [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md).
250
250
 
251
+ ### Environments: Live / Sandbox / Hybrid
252
+
253
+ `config.mode` decides which Dhan host each request hits:
254
+
255
+ | Mode | Market data (quotes, option chain, charts, instruments, ScanX) | Trading & account (orders, positions, funds, statements) |
256
+ | ---------- | -------------------------------------------------------------- | -------------------------------------------------------- |
257
+ | `:live` | `api.dhan.co` (default) | `api.dhan.co` |
258
+ | `:sandbox` | `sandbox.dhan.co` | `sandbox.dhan.co` |
259
+ | `:hybrid` | `api.dhan.co` — **live prices** | `sandbox.dhan.co` — **paper execution** |
260
+
261
+ `hybrid` is the strategy-rehearsal mode: your code reads real market data and places
262
+ orders that are accepted and validated by the Dhan sandbox (which mirrors the live REST
263
+ surface) without risking money.
264
+
265
+ ```ruby
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
270
+ end
271
+
272
+ # Same code, split routing:
273
+ client.market_feed.ltp("NSE_EQ" => [11536]) # -> https://api.dhan.co/v2/marketfeed/ltp
274
+ client.option_chain.fetch(...) # -> https://api.dhan.co/v2/optionchain
275
+ client.orders.create(...) # -> https://sandbox.dhan.co/v2/orders (no LIVE_TRADING needed)
276
+ client.positions.all # -> https://sandbox.dhan.co/v2/positions
277
+ ```
278
+
279
+ Per-tier escape hatch when the defaults don't fit:
280
+
281
+ ```ruby
282
+ config.mode = :hybrid
283
+ config.environment_overrides = { non_trading_api: :live } # e.g. read the real fund limits
284
+ ```
285
+
286
+ - Env vars: `DHAN_MODE=live|sandbox|hybrid` (or the legacy `DHAN_SANDBOX=true`).
287
+ - The `LIVE_TRADING` guard is **relaxed automatically** for requests routed to the
288
+ sandbox host — no real money can move there. Orders still hit the guard on `:live`.
289
+ - Per-client override: `DhanHQ::Client.new(config: shared_config, mode: :hybrid)` forks
290
+ the config instead of mutating the shared one.
291
+ - Global Stocks (US equities) always stay on production in `:hybrid` — the sandbox host
292
+ does not mirror them.
293
+ - WebSocket streams are production-only (Dhan sandbox has no WS); in `:hybrid` the order
294
+ update stream reports your **real** account, not sandbox orders.
295
+ - `sandbox = true` remains a supported alias for `mode = :sandbox`.
296
+
297
+ > **Full details**: what each tier routes where, verified-vs-unverified sandbox endpoints —
298
+ > see [docs/ENDPOINTS_AND_SANDBOX.md](docs/ENDPOINTS_AND_SANDBOX.md).
299
+
300
+ ---
301
+
302
+ ## ScanX Data API (company info, market movers, news, technicals)
303
+
304
+ The `/v2/data/*` endpoints are wrapped by `client.scanx` — fundamentals, ranked movers,
305
+ live news headlines and server-side indicators, all validated by dry-validation contracts:
306
+
307
+ ```ruby
308
+ client.scanx.company_info(security_id: "1333", exchange_segment: "NSE_EQ",
309
+ instrument: "EQUITY", metrics: %w[CO RATIOS SHP])
310
+
311
+ client.scanx.technical_data(security_id: "1333", exchange_segment: "NSE_EQ",
312
+ instrument: "EQUITY", timeframe: "D",
313
+ indicators: %w[SMA_20 RSI_14 MACD_HIST PIVOT_CLASSIC])
314
+
315
+ client.scanx.market_movers(exchange_segment: "NSE_FNO", instrument: %w[OPTIDX OPTSTK],
316
+ category: "HIGHEST_OI", expiry: "2026-10-29", limit: 10)
317
+
318
+ client.scanx.news_headlines(categories: %w[ALL], limit: 25)
319
+ ```
320
+
251
321
  ---
252
322
 
253
323
  ## Order Safety
@@ -266,6 +336,11 @@ LIVE_TRADING=false # or simply omit
266
336
 
267
337
  Attempting to place an order without `LIVE_TRADING=true` raises `DhanHQ::LiveTradingDisabledError`.
268
338
 
339
+ **Sandbox exception:** orders routed to the sandbox host (`mode = :sandbox`, or the trading
340
+ tiers under `mode = :hybrid`) skip this guard automatically — the sandbox cannot move real
341
+ money, so the gate only protects production writes. Every audit log line records the
342
+ `target` host (`live`/`sandbox`) it fired at.
343
+
269
344
  ### Dry-Run Mode
270
345
 
271
346
  `dry_run` suppresses every request that would change account state — order placement,
@@ -24,7 +24,9 @@ Set these _before_ calling `configure_with_env` to override defaults:
24
24
 
25
25
  | Variable | Default | Description |
26
26
  | -------------------------------- | ---------- | ------------------------------------------------------- |
27
- | `DHAN_SANDBOX` | `false` | Set `"true"` to route REST calls to `https://sandbox.dhan.co/v2` instead of production. 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). |
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_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
+ | `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`. |
28
30
  | `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. |
29
31
  | `DHAN_WS_VERSION` | latest | Pin WebSocket connections to a specific API version |
30
32
  | `DHAN_WS_ORDER_URL` | Dhan prod | Override the order update WebSocket endpoint |
@@ -46,7 +48,7 @@ These change what a write request *does*, not just where it goes. All default to
46
48
 
47
49
  | Variable | Config attribute | Default | Effect |
48
50
  | ------------------------------------- | ------------------------------------ | ------- | ------ |
49
- | `LIVE_TRADING=true` | — | unset | **Required to place, modify, or cancel any real order, position exit, kill switch, EDIS, or P&L exit.** Without it, every write-path resource raises `DhanHQ::LiveTradingDisabledError` before making the request. This is the primary safety gate — see [ARCHITECTURE.md](../ARCHITECTURE.md) and the risk pipeline. |
51
+ | `LIVE_TRADING=true` | — | unset | **Required to place, modify, or cancel any real order, position exit, kill switch, EDIS, or P&L exit that targets production.** Without it, every production-bound write-path resource raises `DhanHQ::LiveTradingDisabledError` before making the request. Writes whose effective target is the sandbox host (`DHAN_MODE=sandbox`, or the trading tiers under `hybrid`) are exempt — no real money can move there. This is the primary safety gate — see [ARCHITECTURE.md](../ARCHITECTURE.md) and the risk pipeline. |
50
52
  | `DHAN_DRY_RUN=true` | `config.dry_run` | `false` | Suppresses every state-changing request, logs the payload as `DHAN_DRY_RUN`, and answers order placements with a simulated `DRYRUN-…` id so caller code paths still run to completion. Reads still hit the API. |
51
53
  | `DHAN_RETRY_WRITES=true` | `config.retry_non_idempotent_writes` | `false` | Auto-retries a non-idempotent write (order placement, modify, cancel) after a transient failure (429, 5xx, timeout). Off by default because the API has no idempotency key — a timed-out POST may have already reached the exchange, and retrying it can place a duplicate order. |
52
54
  | `DHAN_AUTO_CORRELATION_ID=true` | `config.auto_correlation_id` | `false` | Fills in a `correlationId` (`dhq-<hex>`) on order placements that lack one, so a timed-out placement can be reconciled via `GET /v2/orders/external/{correlation-id}`. Off by default because it changes the request body; an explicit correlation id is always preserved. |
@@ -1,14 +1,46 @@
1
- # DhanHQ gem — Endpoints and sandbox support
1
+ # DhanHQ gem — Endpoints, environments and sandbox support
2
+
3
+ ## Environments: `:live`, `:sandbox`, `:hybrid`
4
+
5
+ `DhanHQ.configuration.mode` (or `DHAN_MODE`) selects which host each REST request hits.
6
+ The legacy `sandbox = true` / `DHAN_SANDBOX=true` remains an alias for `mode = :sandbox`.
7
+
8
+ | Mode | Data tiers (`data_api`, `quote_api`, `option_chain`) | Trading/account tiers (`order_api`, `non_trading_api`) | Global Stocks (`global_stocks_api`) |
9
+ | ---------- | ---------------------------------------------------- | ------------------------------------------------------ | ----------------------------------- |
10
+ | `:live` (default) | `api.dhan.co/v2` | `api.dhan.co/v2` | `api.dhan.co/v2` |
11
+ | `:sandbox` | `sandbox.dhan.co/v2` | `sandbox.dhan.co/v2` | `sandbox.dhan.co/v2` |
12
+ | `:hybrid` | `api.dhan.co/v2` — live prices | `sandbox.dhan.co/v2` — paper execution | `api.dhan.co/v2` (sandbox does not mirror it) |
13
+
14
+ `hybrid` exists for strategy rehearsal: read live market data, place orders against the
15
+ sandbox host. Per-tier overrides (`environment_overrides`) win over the mode defaults —
16
+ for example keeping the real fund limits while everything else runs on the sandbox:
17
+
18
+ ```ruby
19
+ DhanHQ.configure do |c|
20
+ c.mode = :hybrid
21
+ c.environment_overrides = { non_trading_api: :live } # or String form: "non_trading_api=live"
22
+ end
23
+ ```
24
+
25
+ Per-client override (forks the config, never mutates the shared one):
26
+
27
+ ```ruby
28
+ client = DhanHQ::Client.new(config: shared_config, mode: :hybrid)
29
+ ```
30
+
31
+ Resolution order for any request: explicit `config.base_url` (if set, it wins for every
32
+ request) → `environment_overrides[tier]` → mode default for that tier.
2
33
 
3
34
  ## Sandbox behavior
4
35
 
5
36
  - **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.
6
- - **REST:** When `DhanHQ.configuration.sandbox == true` (or `ENV["DHAN_SANDBOX"]=true`), the client uses `https://sandbox.dhan.co/v2` as the base URL for **all** requests that go through `DhanHQ::Client`. Every wrapped REST endpoint listed below is therefore **sent** to the sandbox host when sandbox is enabled.
7
- - **Sandbox does NOT support WebSocket.** Order updates, market feed, and market depth are **production-only**. The gem always uses production WebSocket URLs regardless of the `sandbox` setting. There are no sandbox WebSocket endpoints in the Dhan v2 API; do not rely on sandbox for real-time streams.
8
- - **Auth endpoints** (`DhanHQ::Auth`) do **not** use sandbox. They always call:
37
+ - **REST:** every wrapped REST endpoint listed below is sent to the host its tier resolves to (see the mode table above) — `:sandbox` sends everything to `https://sandbox.dhan.co/v2`, `:hybrid` splits data vs trading.
38
+ - **Sandbox does NOT support WebSocket.** Order updates, market feed, and market depth are **production-only**. The gem always uses production WebSocket URLs regardless of the mode. There are no sandbox WebSocket endpoints in the Dhan v2 API; do not rely on sandbox for real-time streams. Under `:hybrid` the order-update stream therefore reports your **real** account, not sandbox orders.
39
+ - **LIVE_TRADING gate** is relaxed automatically for any write whose effective target is the sandbox host (no real money at risk). Production-targeted writes still require `ENV["LIVE_TRADING"]="true"`. Order audit log lines carry `"target":"live"|"sandbox"`.
40
+ - **Auth endpoints** (`DhanHQ::Auth`) do **not** follow the mode. They always call:
9
41
  - `https://auth.dhan.co` — token generation
10
- - `https://api.dhan.co/v2` — token renewal
11
- So token generation/renewal always hit production; only data/order REST calls follow the sandbox flag.
42
+ - `https://api.dhan.co/v2` — token renewal
43
+ So token generation/renewal always hit production; only data/order REST calls follow the mode.
12
44
 
13
45
  ---
14
46
 
@@ -41,36 +73,43 @@
41
73
  - `/v2/marketfeed/ltp`, `/v2/marketfeed/ohlc`, `/v2/marketfeed/quote`
42
74
  - `/v2/optionchain`, `/v2/optionchain/expirylist`
43
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`)
44
78
 
45
79
  ---
46
80
 
47
81
  ## REST endpoints integrated in the gem
48
82
 
49
- Paths are as built by the gem (HTTP_PATH + endpoint). Base URL is either `https://api.dhan.co/v2` (production) or `https://sandbox.dhan.co/v2` (sandbox).
83
+ Paths are as built by the gem (HTTP_PATH + endpoint). Base URL is resolved per tier — see the mode table above.
50
84
 
51
85
  | Resource / model | Path(s) | Methods | API type |
52
- |---------------------------|----------------------------------------------|-----------|-----------------|
53
- | **Profile** | `/v2/profile` | GET | non_trading_api |
54
- | **Funds** | `/v2/fundlimit` | GET | non_trading_api |
55
- | **Statements** | `/v2/ledger`, `/v2/trades/{from}/{to}/{page}`| GET | non_trading_api |
56
- | **Orders** | `/v2/orders`, `/v2/orders/{id}`, `/v2/orders/external/{correlation_id}`, `/v2/orders/slicing` | GET, POST, PUT, DELETE | order_api |
57
- | **Positions** | `/v2/positions`, `/v2/positions/convert` | GET, POST, DELETE | order_api |
58
- | **Holdings** | `/v2/holdings` | GET | order_api |
59
- | **Trades** | `/v2/trades`, `/v2/trades/{order_id}` | GET | order_api |
60
- | **Forever orders** | `/v2/forever/orders`, `/v2/forever/orders/{id}` | GET, POST, PUT, DELETE | order_api |
61
- | **Super orders** | `/v2/super/orders`, `/v2/super/orders/{id}`, leg delete | GET, POST, PUT, DELETE | order_api |
62
- | **Kill switch** | `/v2/killswitch` | GET, POST | order_api |
63
- | **Trader control** | `/trader-control` | GET, POST | order_api |
64
- | **IP setup** | `/ip/getIP`, `/ip/setIP`, `/ip/modifyIP` | GET, POST, PUT | order_api |
65
- | **EDIS** | `/edis/tpin`, `/edis/form`, `/edis/bulkform`, `/edis/inquire/{isin}` | GET, POST | order_api |
66
- | **Alert orders** | `/alerts/orders` | GET, POST, PUT, DELETE | order_api |
67
- | **PnL exit** | `/v2/pnlExit` | GET, POST, DELETE | order_api |
68
- | **Margin calculator** | `/v2/margincalculator`, `/v2/margincalculator/multi` | POST | order_api |
69
- | **Instruments** | `/v2/instrument/{segment}` (redirect to CSV) | GET | data_api |
70
- | **Market feed** | `/v2/marketfeed/ltp`, `/v2/marketfeed/ohlc`, `/v2/marketfeed/quote` | POST | data_api / quote_api |
71
- | **Option chain** | `/v2/optionchain`, `/v2/optionchain/expirylist` | POST | data_api |
72
- | **Historical data** | `/v2/charts/historical`, `/v2/charts/intraday` | POST | data_api |
73
- | **Expired options data** | `/v2/charts/rollingoption` | POST | data_api |
86
+ |----------------------------|----------------------------------------------|-----------|-----------------|
87
+ | **Profile** | `/v2/profile` | GET | non_trading_api |
88
+ | **Funds** | `/v2/fundlimit` | GET | order_api |
89
+ | **Statements** | `/v2/ledger`, `/v2/trades/{from}/{to}/{page}`| GET | non_trading_api |
90
+ | **Orders** | `/v2/orders`, `/v2/orders/{id}`, `/v2/orders/external/{correlation_id}`, `/v2/orders/slicing` | GET, POST, PUT, DELETE | order_api |
91
+ | **Positions** | `/v2/positions`, `/v2/positions/convert` | GET, POST, DELETE | order_api |
92
+ | **Holdings** | `/v2/holdings` | GET | order_api |
93
+ | **Trades** | `/v2/trades`, `/v2/trades/{order_id}` | GET | order_api |
94
+ | **Forever orders** | `/v2/forever/orders`, `/v2/forever/orders/{id}` | GET, POST, PUT, DELETE | order_api |
95
+ | **Super orders** | `/v2/super/orders`, `/v2/super/orders/{id}`, leg delete | GET, POST, PUT, DELETE | order_api |
96
+ | **Kill switch** | `/v2/killswitch` | GET, POST | order_api |
97
+ | **Trader control** | `/trader-control` | GET, POST | order_api |
98
+ | **IP setup** | `/ip/getIP`, `/ip/setIP`, `/ip/modifyIP` | GET, POST, PUT | order_api |
99
+ | **EDIS** | `/edis/tpin`, `/edis/form`, `/edis/bulkform`, `/edis/inquire/{isin}` | GET, POST | order_api |
100
+ | **Alert orders** | `/alerts/orders` | GET, POST, PUT, DELETE | order_api |
101
+ | **PnL exit** | `/v2/pnlExit` | GET, POST, DELETE | order_api |
102
+ | **Margin calculator** | `/v2/margincalculator`, `/v2/margincalculator/multi` | POST | order_api |
103
+ | **Instruments** | `/v2/instrument/{segment}` (redirect to CSV) | GET | data_api |
104
+ | **Market feed** | `/v2/marketfeed/ltp`, `/v2/marketfeed/ohlc`, `/v2/marketfeed/quote` | POST | data_api / quote_api |
105
+ | **Option chain** | `/v2/optionchain`, `/v2/optionchain/expirylist` | POST | data_api |
106
+ | **Historical data** | `/v2/charts/historical`, `/v2/charts/intraday` | POST | data_api |
107
+ | **Expired options data** | `/v2/charts/rollingoption` | POST | data_api |
108
+ | **ScanX — company info** | `/v2/data/companyinfo` | POST | data_api |
109
+ | **ScanX — market movers** | `/v2/data/marketmovers` | POST | data_api |
110
+ | **ScanX — news** | `/v2/data/newsheadline` | POST | data_api |
111
+ | **ScanX — technicals** | `/v2/data/technical` | POST | data_api |
112
+ | **Global Stocks** | `/v2/globalstocks/orders`, `/v2/globalstocks/holdings`, `/v2/globalstocks/fundlimit`, `/v2/globalstocks/trades`, `/v2/globalstocks/marketstatus`, `/v2/globalstocks/margincalculator`, `/v2/globalstocks/transEstimate` | GET/POST/PUT/DELETE | global_stocks_api |
74
113
 
75
114
  ---
76
115
 
@@ -81,7 +120,7 @@ Paths are as built by the gem (HTTP_PATH + endpoint). Base URL is either `https:
81
120
  | Generate token | `https://auth.dhan.co/app/generateAccessToken` | POST |
82
121
  | Renew token | `https://api.dhan.co/v2/RenewToken` | POST |
83
122
 
84
- These are **not** sandbox-aware; they always use the URLs above.
123
+ These are **not** mode-aware; they always use the URLs above.
85
124
 
86
125
  ---
87
126
 
@@ -93,24 +132,30 @@ These are **not** sandbox-aware; they always use the URLs above.
93
132
  | Market feed | `wss://api-feed.dhan.co` |
94
133
  | Market depth | `wss://depth-api-feed.dhan.co/twentydepth` |
95
134
 
96
- **Sandbox:** Dhan sandbox does **not** provide WebSocket services. These endpoints are production-only. The gem never switches WS URLs based on `sandbox`; you can still override via `DhanHQ.configuration.ws_order_url`, `ws_market_feed_url`, `ws_market_depth_url`, or env vars `DHAN_WS_ORDER_URL`, `DHAN_WS_MARKET_FEED_URL`, `DHAN_WS_MARKET_DEPTH_URL` if you have a different production URL.
135
+ **Sandbox:** Dhan sandbox does **not** provide WebSocket services. These endpoints are production-only. The gem never switches WS URLs based on the mode; you can still override via `DhanHQ.configuration.ws_order_url`, `ws_market_feed_url`, `ws_market_depth_url`, or env vars `DHAN_WS_ORDER_URL`, `DHAN_WS_MARKET_FEED_URL`, `DHAN_WS_MARKET_DEPTH_URL` if you have a different production URL.
97
136
 
98
137
  ---
99
138
 
100
139
  ## Summary
101
140
 
102
- - **REST:** When `sandbox` is true, all REST calls go to `https://sandbox.dhan.co/v2`. Only `GET /v2/profile` and `GET /v2/fundlimit` are verified working on sandbox; other REST endpoints are not verified — see "Sandbox: verified vs not working / unverified" above.
141
+ - **Modes:** `:live` (default — everything on production), `:sandbox` (everything on `sandbox.dhan.co`), `:hybrid` (data tiers live, trading tiers sandbox, Global Stocks live). Set via `config.mode`, `DHAN_MODE`, or the legacy `config.sandbox` / `DHAN_SANDBOX`.
142
+ - **Verified on sandbox:** only `GET /v2/profile` and `GET /v2/fundlimit` — see "Sandbox: verified vs not working / unverified" above.
143
+ - **LIVE_TRADING:** required only for writes that target production; sandbox-targeted writes are exempt.
103
144
  - **Auth:** Token generation and renewal always use production hosts.
104
145
  - **WebSockets:** Sandbox does **not** support WebSocket. Order updates, market feed, and market depth always use production URLs; the gem does not publish or use any sandbox WebSocket URLs.
105
146
 
106
- ## Call-all-endpoints script
147
+ ## Smoke testing
107
148
 
108
- `bin/call_all_endpoints.rb` invokes every REST endpoint exposed by the gem (read-only by default; use `--all` to include write/destructive calls). Useful for connectivity checks or sandbox verification.
149
+ There is no shipped call-all-endpoints script (an earlier revision of this page
150
+ referenced `bin/call_all_endpoints.rb`, which does not exist in the repo). Use
151
+ what is actually here:
109
152
 
110
153
  ```bash
111
- bin/call_all_endpoints.rb # read-only
112
- bin/call_all_endpoints.rb --list # print endpoint list
113
- bin/call_all_endpoints.rb --all # include POST/PUT/DELETE
154
+ bin/console # IRB with the gem preloaded — try client.profile.get,
155
+ # client.market_feed.ltp(...), client.fundlimit.get ...
156
+ bundle exec rspec spec/dhan_hq/sandbox_connectivity_spec.rb # guarded sandbox probes
157
+ bin/ws_feed_test.rb # live market-feed smoke test (production WS only)
114
158
  ```
115
159
 
116
- Requires `DHAN_CLIENT_ID` and `DHAN_ACCESS_TOKEN`. Optional: `DHAN_SANDBOX=true`, `DHAN_TEST_SECURITY_ID`, `DHAN_TEST_ORDER_ID`, `DHAN_TEST_ISIN`, `DHAN_TEST_EXPIRY`. When not using `--skip-unavailable`, the script creates a temporary alert for GET/PUT/DELETE alert endpoints and deletes it at exit.
160
+ Requires `DHAN_CLIENT_ID` and `DHAN_ACCESS_TOKEN`. Optional: `DHAN_MODE=hybrid`
161
+ (or `DHAN_SANDBOX=true`).
data/lib/DhanHQ/client.rb CHANGED
@@ -49,8 +49,12 @@ module DhanHQ
49
49
  # @param config_or_attrs [DhanHQ::Configuration, Hash, nil] Optional configuration or attribute hash
50
50
  # @param config [DhanHQ::Configuration, nil] Optional keyword configuration
51
51
  # @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
52
+ # @param mode [Symbol, nil] Optional environment override (`:live`, `:sandbox`, `:hybrid`)
53
+ # for this client. When combined with +config:+ the configuration is forked,
54
+ # so a shared/global configuration is never mutated.
52
55
  # @param attrs [Hash] Additional keyword configuration attributes
53
- def initialize(config_or_attrs = nil, config: nil, api_type: :order_api, **attrs)
56
+ def initialize(config_or_attrs = nil, config: nil, api_type: :order_api, mode: nil, **attrs)
57
+ attrs = attrs.merge(mode: mode) if mode
54
58
  @config = resolve_config(config_or_attrs, config, attrs)
55
59
  @api_type = api_type
56
60
  @rate_limiter = RateLimiter.for(api_type)
@@ -59,11 +63,12 @@ module DhanHQ
59
63
  end
60
64
 
61
65
  # The Faraday connection used for HTTP requests.
62
- # Built on first use and rebuilt whenever the configured base URL changes.
66
+ # Built on first use and rebuilt whenever the base URL for this client's API
67
+ # tier changes — including a mid-flight mode switch.
63
68
  #
64
69
  # @return [Faraday::Connection]
65
70
  def connection
66
- current_url = @config.base_url
71
+ current_url = @config.base_url_for(@api_type)
67
72
  return @connection if @connection && @last_base_url == current_url
68
73
 
69
74
  @last_base_url = current_url
@@ -92,6 +97,15 @@ module DhanHQ
92
97
  def trader_control = @trader_control ||= Resources::TraderControl.new(client: client_for(Resources::TraderControl::API_TYPE))
93
98
  def ip_setup = @ip_setup ||= Resources::IPSetup.new(client: client_for(Resources::IPSetup::API_TYPE))
94
99
  def edis = @edis ||= Resources::Edis.new(client: client_for(Resources::Edis::API_TYPE))
100
+ def scanx = @scanx ||= Resources::ScanX.new(client: client_for(Resources::ScanX::API_TYPE))
101
+
102
+ # The effective environment (+:live+ or +:sandbox+) for requests issued by
103
+ # this client, resolved from its API tier and the configured mode.
104
+ #
105
+ # @return [Symbol]
106
+ def environment
107
+ @config.environment_for(@api_type)
108
+ end
95
109
 
96
110
  # WebSocket streams bound to this client instance configuration
97
111
  def ws_market_feed(mode: :ticker, url: nil, &block)
@@ -258,11 +272,11 @@ module DhanHQ
258
272
 
259
273
  def resolve_config(config_or_attrs, keyword_config, attrs = {})
260
274
  if config_or_attrs.is_a?(DhanHQ::Configuration)
261
- config_or_attrs
275
+ fork_config_with_attrs(config_or_attrs, attrs)
262
276
  elsif config_or_attrs.is_a?(Hash)
263
277
  DhanHQ::Configuration.new(config_or_attrs.merge(attrs))
264
278
  elsif keyword_config
265
- keyword_config
279
+ fork_config_with_attrs(keyword_config, attrs)
266
280
  elsif attrs.any?
267
281
  DhanHQ::Configuration.new(attrs)
268
282
  else
@@ -270,6 +284,17 @@ module DhanHQ
270
284
  end
271
285
  end
272
286
 
287
+ # Copies a Configuration when per-instance attributes (e.g. +mode:+) are
288
+ # given alongside it, so an injected or global configuration is never
289
+ # mutated. Without extra attributes the original object is shared unchanged.
290
+ def fork_config_with_attrs(config, attrs)
291
+ return config if attrs.empty?
292
+
293
+ config.dup.tap do |forked|
294
+ attrs.each { |key, value| forked.public_send("#{key}=", value) }
295
+ end
296
+ end
297
+
273
298
  # Simulator that answers state-changing requests locally when dry run is on.
274
299
  #
275
300
  # @return [DhanHQ::DryRun::Simulator]
@@ -37,13 +37,28 @@ module DhanHQ
37
37
  module OrderAudit
38
38
  private
39
39
 
40
- # Raises an error if LIVE_TRADING is not explicitly enabled.
41
- # Set ENV["LIVE_TRADING"]="true" in production to allow order submission.
40
+ # Raises an error unless live trading is explicitly enabled OR the request
41
+ # targets the sandbox host, where no real money is at risk.
42
+ #
43
+ # Set ENV["LIVE_TRADING"]="true" to allow order submission against the
44
+ # production host. Orders routed to the sandbox host (config.mode =
45
+ # :sandbox, or the trading tiers under :hybrid) skip the gate because they
46
+ # cannot place real orders.
42
47
  def ensure_live_trading!
43
48
  return if ENV["LIVE_TRADING"] == "true"
49
+ return if sandbox_target?
44
50
 
45
51
  raise DhanHQ::LiveTradingDisabledError,
46
- "Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement."
52
+ "Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement, " \
53
+ "or switch to the sandbox with DhanHQ.configure { |c| c.mode = :sandbox } / :hybrid."
54
+ end
55
+
56
+ # True when this resource's requests resolve to the sandbox host.
57
+ # Tolerates hosts that never got a client wired in (bare test doubles).
58
+ def sandbox_target?
59
+ return false unless respond_to?(:client) && client.respond_to?(:environment)
60
+
61
+ client.environment == :sandbox
47
62
  end
48
63
 
49
64
  # Runs the risk pipeline for the given order params.
@@ -120,6 +135,7 @@ module DhanHQ
120
135
  event: event,
121
136
  hostname: inspector.hostname,
122
137
  env: inspector.environment,
138
+ target: request_target,
123
139
  ipv4: inspector.public_ipv4,
124
140
  ipv6: inspector.public_ipv6,
125
141
  security_id: extract_param(params, :securityId, :security_id),
@@ -131,6 +147,13 @@ module DhanHQ
131
147
  DhanHQ.logger&.warn(JSON.generate(entry))
132
148
  end
133
149
 
150
+ # The host the order will be sent to, for the audit trail.
151
+ def request_target
152
+ return "sandbox" if sandbox_target?
153
+
154
+ "live"
155
+ end
156
+
134
157
  # Extracts a value from params trying both camelCase and snake_case keys,
135
158
  # as well as both symbol and string key types.
136
159
  def extract_param(params, camel_key, snake_key)