DhanHQ 3.4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -1
  3. data/GUIDE.md +40 -0
  4. data/README.md +103 -0
  5. data/docs/CONFIGURATION.md +29 -2
  6. data/docs/ENDPOINTS_AND_SANDBOX.md +83 -38
  7. data/lib/DhanHQ/client.rb +101 -26
  8. data/lib/DhanHQ/concerns/order_audit.rb +26 -3
  9. data/lib/DhanHQ/configuration.rb +178 -27
  10. data/lib/DhanHQ/constants.rb +60 -1
  11. data/lib/DhanHQ/contracts/company_info_contract.rb +24 -0
  12. data/lib/DhanHQ/contracts/market_movers_contract.rb +57 -0
  13. data/lib/DhanHQ/contracts/news_headline_contract.rb +21 -0
  14. data/lib/DhanHQ/contracts/technical_data_contract.rb +25 -0
  15. data/lib/DhanHQ/core/base_api.rb +3 -17
  16. data/lib/DhanHQ/core/base_model.rb +32 -12
  17. data/lib/DhanHQ/dry_run/simulator.rb +3 -2
  18. data/lib/DhanHQ/helpers/attribute_helper.rb +0 -22
  19. data/lib/DhanHQ/helpers/request_helper.rb +13 -3
  20. data/lib/DhanHQ/models/option_chain.rb +7 -0
  21. data/lib/DhanHQ/models/order.rb +7 -7
  22. data/lib/DhanHQ/rate_limiter.rb +34 -40
  23. data/lib/DhanHQ/resources/global_stocks/funds.rb +1 -1
  24. data/lib/DhanHQ/resources/global_stocks/holdings.rb +1 -1
  25. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +1 -1
  26. data/lib/DhanHQ/resources/global_stocks/market_status.rb +1 -1
  27. data/lib/DhanHQ/resources/global_stocks/orders.rb +1 -1
  28. data/lib/DhanHQ/resources/global_stocks/trades.rb +1 -1
  29. data/lib/DhanHQ/resources/market_feed.rb +1 -1
  30. data/lib/DhanHQ/resources/scanx.rb +98 -0
  31. data/lib/DhanHQ/version.rb +1 -1
  32. data/lib/DhanHQ/ws/base_connection.rb +2 -1
  33. data/lib/DhanHQ/ws/client.rb +10 -13
  34. data/lib/DhanHQ/ws/connection.rb +13 -15
  35. data/lib/DhanHQ/ws/market_depth/client.rb +9 -4
  36. data/lib/DhanHQ/ws/orders/client.rb +4 -4
  37. data/lib/DhanHQ/ws/orders/connection.rb +1 -1
  38. data/lib/DhanHQ/ws/registry.rb +10 -6
  39. data/lib/dhan_hq.rb +81 -48
  40. metadata +27 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d97f7473f5bb7c4968750c3e21e5721a9c9ba1261e6e00d450818613edb74ec
4
- data.tar.gz: 49464e4909ac0f52947fc86735b6b43bef6877f8ba0a1f796eb3b1c58c60bfde
3
+ metadata.gz: d79f2577f59f6c04874eb44e4d318e5584e711e205be25f6fa000053ec772846
4
+ data.tar.gz: dca8144795ab2b77700a3947512c04e73c8edcbaa287b74adb746e671158e583
5
5
  SHA512:
6
- metadata.gz: 5a70be1d7aa2af222df63e8188af1b60e110431d73ae36317244693d974e46a3fcb84a9a49d9276fbadeea105c1124753129aa1c057d089924f0b71c7075f481
7
- data.tar.gz: cb8a910ad46ae08b570eb9b19ac3a553de9a050e1b2a409d1414f3eb18d7eb762cb2fc62d6f2a5a72ad9014f726a1195541fa29ea80d749ba4c1dc21d6b0903f
6
+ metadata.gz: e5a38d2663377da284188dc32fd0e796394ee899a34b61d5f82b5a487b0a47477a9e6bc1c0fdfc0b7fa93959d8673e257a83b7eea1af1a48885526ecc506a89f
7
+ data.tar.gz: 3f2023c47fc5e5b1f6fcce29f704c2c4ac03ad887a91dbc0c33bba201a151e8f03793bdc736915a295e98a5e661810e6229e32b37ea11c09b977fbcbb1b1c5d3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,61 @@
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
+
40
+ ## [4.0.0] - 2026-08-31
41
+
42
+ ### Added
43
+
44
+ - **Multi-Instance Client Isolation (`DhanHQ::Client.new`)** — run multiple isolated client instances in the same process with separate configurations, credentials, sandbox environments, and rate limiters. Ideal for multi-tenant applications, managing multiple trading accounts concurrently, or comparing sandbox vs. production environments side-by-side.
45
+ ```ruby
46
+ client_a = DhanHQ::Client.new(client_id: "ACC_A", access_token: "TOKEN_A")
47
+ client_b = DhanHQ::Client.new(client_id: "ACC_B", access_token: "TOKEN_B", sandbox: true)
48
+
49
+ orders_a = client_a.orders.all
50
+ orders_b = client_b.orders.all
51
+ ```
52
+ - **Instance-bound Resource Accessors** — direct accessors on `DhanHQ::Client` instances (`client.orders`, `client.positions`, `client.holdings`, `client.funds`, `client.trades`, `client.market_feed`, `client.option_chain`, `client.historical_data`, `client.forever_orders`, `client.alert_orders`, `client.iceberg_orders`, `client.super_orders`, `client.twap_orders`, `client.statements`, `client.kill_switch`, `client.pnl_exit`, `client.trader_control`, `client.ip_setup`, `client.edis`) bound to that client's configuration and routed to matching rate-limiting tiers (`client_for(API_TYPE)`).
53
+ - **Instance-bound WebSocket Helpers** — `client.ws_market_feed`, `client.ws_order_update`, and `client.ws_market_depth` configured automatically with the client instance's credentials and URLs.
54
+ - **Keyword Argument Initialization** — `DhanHQ::Client.new` accepts keyword configuration parameters (`dry_run: true`, `sandbox: true`, `client_id: "..."`, `access_token: "..."`) or a `Configuration` instance.
55
+ - **Per-Instance Dry-Run Mode** — `DhanHQ::Client.new(dry_run: true)` simulates state-changing writes locally for that instance regardless of the global configuration.
56
+ - **Dynamic Model Resource Invalidation** — `DhanHQ.reset_model_resources!` automatically refreshes class-level model caches (`Models::Order.resource`, etc.) whenever `DhanHQ.configure`, `configure_with_env`, `configuration=`, or `reset_configuration!` is invoked.
57
+ - **Per-Client Auto Token Management** — `client.enable_auto_token_management!(dhan_client_id: ..., pin: ..., totp_secret: ...)` for automated TOTP-based token lifecycle management per client instance.
58
+
1
59
  ## [3.4.0] - 2026-08-15
2
60
 
3
61
  ### Added
@@ -19,7 +77,9 @@
19
77
  ### Fixed
20
78
 
21
79
  - **`docs/RAILS_INTEGRATION.md`'s Sidekiq examples (§5, §6) called `client.wait!`, `client.subscribe(array)`, and `DhanHQ::WS::Client.new(kind: :order_updates)`** — none of which exist. `DhanHQ::WS::Client`/`DhanHQ::WS::Orders::Client` have no blocking wait method at all; `Client#start` spawns a background thread and returns immediately. Replaced with the real API (`DhanHQ::WS.connect`/`DhanHQ::WS::Orders.connect` plus a `connected?`-based liveness loop), guarded by a new spec that locks down the method names these examples call by name.
22
- - **`dry-validation` had no version floor in the gemspec.** A fresh `bundle install` could resolve it to `0.4.1` — a 2016-era, pre-`Dry::Validation::Contract` API generation every contract in `lib/DhanHQ/contracts/` is incompatible with. Pinned to `~> 1.11`. Found while investigating whether the Ruby floor could drop to 3.1 (it can't — see below); confirmed by forcing an actual fresh resolve under Ruby 3.1.6, which hit exactly this.
80
+ - **Every runtime dependency except `faraday` had no version floor at all** (`add_dependency "x"`, equivalent to `>= 0` — satisfied by any version ever published). Found while investigating whether the Ruby floor could drop to 3.1 (it can't — see below): forcing a fresh `bundle install` under Ruby 3.1.6 resolved `dry-validation` to `0.4.1`, a 2016-era, pre-`Dry::Validation::Contract` API generation every contract in `lib/DhanHQ/contracts/` is incompatible with — every contract spec failed at `require`-time. Pinning that alone wasn't enough: with `dry-validation` fixed, the same fresh resolve then pulled `activesupport` down to `7.2.3.2` (ActiveSupport 8.x itself requires Ruby ≥ 3.2), and AS 7-vs-8 behavioral differences broke `Agent::ToolRegistry`, `MCP::Server`, and `Risk::Pipeline` — 63 failures with no connection to Ruby version syntax at all.
81
+
82
+ All ten now have an explicit floor matching their currently locked major (`activesupport >= 8.0`, `dry-validation ~> 1.0`, and so on) — closing the class of bug, not just the two instances that happened to surface. The `dry-validation` constraint went through two attempts: the first pass in this same release pinned it to `~> 1.11`, which — because `~>` locks to the *last* segment given — actually meant `>= 1.11, < 1.12` and would have silently blocked every future 1.12+ release. Corrected to `~> 1.0`, which excludes the ancient `0.x` line (the actual problem) without capping legitimate 1.x updates.
23
83
  - **`spec/dhan_hq/contracts/expired_options_data_contract_spec.rb` and `expired_options_data_spec.rb` hardcoded `from_date: "2021-08-02"`**, which is itself now more than 5 years in the past and so failed the contract's own "cannot be more than 5 years ago" rule — the shared fixture failed the exact rule it existed to exercise, cascading into every test merged onto it. Replaced every literal 2021 date with one computed relative to `Date.today`, preserving each test's original intent (span length, ordering, the exact-31-day boundary).
24
84
 
25
85
  ### Investigated, not changed
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. |
@@ -77,6 +79,44 @@ For token rotation without restarting the app, set `access_token_provider` (Proc
77
79
 
78
80
  See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) and README “Dynamic access token”.
79
81
 
82
+ **Multi-instance clients (multi-account support)**
83
+
84
+ When you need to trade across multiple accounts, run multi-tenant setups, or isolate sandbox/testing environments in a single process, use `DhanHQ::Client.new`:
85
+
86
+ ```ruby
87
+ # Account 1
88
+ client1 = DhanHQ::Client.new(client_id: "ACC_1", access_token: "TOKEN_1")
89
+
90
+ # Account 2 (Sandbox)
91
+ client2 = DhanHQ::Client.new(client_id: "ACC_2", access_token: "TOKEN_2", sandbox: true)
92
+
93
+ # Resource accessors directly bound to client instance:
94
+ orders1 = client1.orders.all
95
+ orders2 = client2.orders.all
96
+ ```
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
+
80
120
  ---
81
121
 
82
122
  ## Working With Models
data/README.md CHANGED
@@ -218,8 +218,106 @@ end
218
218
 
219
219
  When the API returns 401, the client retries **once** with a fresh token from your provider.
220
220
 
221
+ ### Multi-Instance Clients (Multi-Account / Multi-Tenant)
222
+
223
+ For multi-tenant applications, managing multiple trading accounts concurrently, or isolating sandbox from production within the same process, construct explicit `DhanHQ::Client` instances:
224
+
225
+ ```ruby
226
+ # Account 1 (Live Trading)
227
+ client1 = DhanHQ::Client.new(
228
+ client_id: "ACC_1_ID",
229
+ access_token: "ACC_1_TOKEN"
230
+ )
231
+
232
+ # Account 2 (Sandbox / Testing)
233
+ client2 = DhanHQ::Client.new(
234
+ client_id: "ACC_2_ID",
235
+ access_token: "ACC_2_TOKEN",
236
+ sandbox: true,
237
+ dry_run: true
238
+ )
239
+
240
+ # Resource accessors are bound to the client instance:
241
+ positions = client1.positions.all
242
+ orders = client2.orders.all
243
+
244
+ # Instance-bound WebSocket streams:
245
+ ws_stream = client1.ws_market_feed(mode: :ticker) { |tick| puts tick[:ltp] }
246
+ ws_orders = client1.ws_order_update
247
+ ```
248
+
221
249
  > **Full details**: TOTP flows, partner mode, token endpoint bootstrap, auto-management — see [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md).
222
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
+
223
321
  ---
224
322
 
225
323
  ## Order Safety
@@ -238,6 +336,11 @@ LIVE_TRADING=false # or simply omit
238
336
 
239
337
  Attempting to place an order without `LIVE_TRADING=true` raises `DhanHQ::LiveTradingDisabledError`.
240
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
+
241
344
  ### Dry-Run Mode
242
345
 
243
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. |
@@ -106,6 +108,31 @@ end
106
108
  - **`access_token_provider`**: Callable (Proc/lambda) returning the token string. Called on every request (no memoization). When set, the gem uses it instead of `access_token`.
107
109
  - **`on_token_expired`**: Optional callable invoked when a 401/token-expired triggers a **single retry** (only when `access_token_provider` is set).
108
110
 
111
+ ## Multi-Instance Client Configuration
112
+
113
+ For multi-account or multi-tenant architectures, construct explicit `DhanHQ::Client` instances without depending on global singleton state:
114
+
115
+ ```ruby
116
+ # Direct configuration via keyword arguments
117
+ client = DhanHQ::Client.new(
118
+ client_id: "YOUR_CLIENT_ID",
119
+ access_token: "YOUR_ACCESS_TOKEN",
120
+ sandbox: false,
121
+ dry_run: false
122
+ )
123
+
124
+ # Or via an explicit Configuration object
125
+ custom_config = DhanHQ::Configuration.new do |c|
126
+ c.client_id = "TENANT_CLIENT_ID"
127
+ c.access_token = "TENANT_ACCESS_TOKEN"
128
+ c.dry_run = true
129
+ end
130
+
131
+ client = DhanHQ::Client.new(config: custom_config)
132
+ ```
133
+
134
+ Each client instance manages its own Faraday connection, isolated configuration, dry-run state, and instance-bound resource accessors (`client.orders`, `client.positions`, `client.market_feed`, etc.).
135
+
109
136
  For detailed authentication flows, see [AUTHENTICATION.md](AUTHENTICATION.md).
110
137
 
111
138
  ## Available Resources
@@ -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`).