DhanHQ 3.3.0 → 4.0.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 +4 -4
- data/CHANGELOG.md +49 -0
- data/GUIDE.md +16 -0
- data/README.md +67 -17
- data/docs/CONFIGURATION.md +26 -0
- data/docs/RAILS_INTEGRATION.md +60 -17
- data/docs/TROUBLESHOOTING.md +15 -1
- data/lib/DhanHQ/backtest/result.rb +87 -0
- data/lib/DhanHQ/backtest/runner.rb +144 -0
- data/lib/DhanHQ/backtest/trade.rb +35 -0
- data/lib/DhanHQ/backtest.rb +24 -0
- data/lib/DhanHQ/client.rb +76 -26
- data/lib/DhanHQ/configuration.rb +40 -23
- data/lib/DhanHQ/core/base_api.rb +3 -2
- data/lib/DhanHQ/dry_run/simulator.rb +3 -2
- data/lib/DhanHQ/helpers/request_helper.rb +13 -3
- data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
- data/lib/DhanHQ/resources/market_feed.rb +1 -1
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/base_connection.rb +3 -1
- data/lib/DhanHQ/ws/client.rb +7 -5
- data/lib/DhanHQ/ws/connection.rb +1 -0
- data/lib/DhanHQ/ws/market_depth/client.rb +4 -3
- data/lib/DhanHQ/ws/orders/client.rb +4 -4
- data/lib/DhanHQ/ws/orders/connection.rb +2 -1
- data/lib/DhanHQ/ws.rb +25 -0
- data/lib/dhan_hq.rb +74 -46
- data/lib/generators/dhanhq/install/install_generator.rb +45 -0
- data/lib/generators/dhanhq/install/templates/initializer.rb +18 -0
- data/lib/generators/dhanhq/install/templates/market_feed_channel.rb +7 -0
- data/lib/generators/dhanhq/install/templates/market_feed_worker.rb +27 -0
- data/lib/generators/dhanhq/install/templates/place_order_service.rb +24 -0
- metadata +44 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c2f0784210b7f48ccb056b8cc486ce5c3ef655f69a4bd2834a150b1d04a8bc7
|
|
4
|
+
data.tar.gz: a6628fc83e8ec61bd4ca4b0eb6147a67232156f98ab49196c0a81850f9b4ac59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0a4147017f7722d9a74cec3105fb57e9b8710142abda6c04d5948caa7500b21ce8c31958cc501e99643a31afffe5e64846b5a73ff239f1a8fb87074e57a6856
|
|
7
|
+
data.tar.gz: 508f55621273349e9060e941cc9bf8b7cef7fb28befb8026b57b977ba0954760f783eb5dbb26c19593ac113232c12fa74884fcd57a81b61dd21dbc3d66c7b9e9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
+
## [4.0.0] - 2026-08-31
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
```ruby
|
|
7
|
+
client_a = DhanHQ::Client.new(client_id: "ACC_A", access_token: "TOKEN_A")
|
|
8
|
+
client_b = DhanHQ::Client.new(client_id: "ACC_B", access_token: "TOKEN_B", sandbox: true)
|
|
9
|
+
|
|
10
|
+
orders_a = client_a.orders.all
|
|
11
|
+
orders_b = client_b.orders.all
|
|
12
|
+
```
|
|
13
|
+
- **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)`).
|
|
14
|
+
- **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.
|
|
15
|
+
- **Keyword Argument Initialization** — `DhanHQ::Client.new` accepts keyword configuration parameters (`dry_run: true`, `sandbox: true`, `client_id: "..."`, `access_token: "..."`) or a `Configuration` instance.
|
|
16
|
+
- **Per-Instance Dry-Run Mode** — `DhanHQ::Client.new(dry_run: true)` simulates state-changing writes locally for that instance regardless of the global configuration.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
20
|
+
## [3.4.0] - 2026-08-15
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`DhanHQ::Backtest::Runner`** (with `Trade` and `Result`) — replays a `DhanHQ::Strategy::Base` against historical `OHLCSeries` candles and returns a trade log, a per-bar equity curve, and summary stats (`total_return_pct`, `win_rate`, `max_drawdown_pct`, `num_trades`, `avg_trade_pnl`).
|
|
25
|
+
|
|
26
|
+
Both entries and exits fill at the *next* candle's open, never the signal candle's own price — deciding to act on a candle and then filling somewhere inside that same candle is look-ahead bias, since the fill price would have to come from before the candle closed. The equity curve stays flat on the signal bar itself for the same reason: a position isn't marked-to-market until it's actually been filled on the following bar. Risk-rule violations reuse `Strategy::Base#check_risks` rather than a second DSL, and an unclosed position at the end of the dataset is force-closed at the final candle's close. Optional `max_bars_held:` guards against a strategy bug holding a position across the entire dataset.
|
|
27
|
+
|
|
28
|
+
- **`QUICKSTART.md`** — the 5 most common tasks (install/config, reads, safe order placement, WS streaming, strategy building) in under 50 lines, linked from the README.
|
|
29
|
+
|
|
30
|
+
- **`DHAN_WS_DEBUG=true`** (`config.ws_debug`) — hex-dumps every raw inbound WebSocket frame at `debug` level before it's parsed, for troubleshooting binary parse errors and dead-but-connected feeds. Off by default, checked before any hex-encoding work so there's no cost when disabled, and capped at 256 bytes per frame (with a `...truncated` marker and the full byte count always logged) so a market-depth feed at tick frequency can't flood the log.
|
|
31
|
+
|
|
32
|
+
There isn't a single choke point all raw frames pass through: the market-feed `DhanHQ::WS::Connection` predates `BaseConnection` and has its own `on(:message)` handler, `DhanHQ::WS::Orders::Connection` overrides `BaseConnection#handle_message` entirely without calling `super`, and only `DhanHQ::WS::MarketDepth::Client` goes through `BaseConnection` unmodified. `DhanHQ::WS.debug_frame` is wired into all three so there's one hex-dump implementation, not three that could drift.
|
|
33
|
+
|
|
34
|
+
- **`rails generate dhanhq:install`** — scaffolds `config/initializers/dhanhq.rb`, an order-placing service object, a Sidekiq market-feed worker, and an ActionCable channel in one command.
|
|
35
|
+
|
|
36
|
+
- **`DhanHQ::Jobs::PlaceOrderJob`** — an ActiveJob wrapper around `Order.place!`. Uses `discard_on` for `DhanHQ::OrderError`/`DhanHQ::RiskViolation` rather than a manual `rescue`: `discard_on` is handled inside ActiveJob's own `execute`, before an exception would ever reach a queue adapter's own backend-level retry (Sidekiq retries unhandled exceptions by default, independent of ActiveJob's opt-in `retry_on`) — the only adapter-agnostic way to guarantee this non-idempotent write is never silently retried.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`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.
|
|
41
|
+
- **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.
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
- **`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).
|
|
45
|
+
|
|
46
|
+
### Investigated, not changed
|
|
47
|
+
|
|
48
|
+
- **Lowering `required_ruby_version` below `3.2.0`.** This gem's own code needed only two trivial fixes (anonymous `**` keyword forwarding, genuinely 3.2-only syntax — endless methods and anonymous `&` block forwarding, the original suspects, are 3.0+ and 3.1+ respectively and were never the issue). But forcing a fresh dependency resolve under Ruby 3.1.6 — after fixing the `dry-validation` pin above — still forced `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 across areas with no connection to Ruby version syntax at all. That's a materially larger compatibility surface than a floor bump, so `required_ruby_version` stays `>= 3.2.0`.
|
|
49
|
+
|
|
1
50
|
## [3.3.0] - 2026-07-26
|
|
2
51
|
|
|
3
52
|
### Added
|
data/GUIDE.md
CHANGED
|
@@ -77,6 +77,22 @@ For token rotation without restarting the app, set `access_token_provider` (Proc
|
|
|
77
77
|
|
|
78
78
|
See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) and README “Dynamic access token”.
|
|
79
79
|
|
|
80
|
+
**Multi-instance clients (multi-account support)**
|
|
81
|
+
|
|
82
|
+
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`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# Account 1
|
|
86
|
+
client1 = DhanHQ::Client.new(client_id: "ACC_1", access_token: "TOKEN_1")
|
|
87
|
+
|
|
88
|
+
# Account 2 (Sandbox)
|
|
89
|
+
client2 = DhanHQ::Client.new(client_id: "ACC_2", access_token: "TOKEN_2", sandbox: true)
|
|
90
|
+
|
|
91
|
+
# Resource accessors directly bound to client instance:
|
|
92
|
+
orders1 = client1.orders.all
|
|
93
|
+
orders2 = client2.orders.all
|
|
94
|
+
```
|
|
95
|
+
|
|
80
96
|
---
|
|
81
97
|
|
|
82
98
|
## Working With Models
|
data/README.md
CHANGED
|
@@ -1,31 +1,44 @@
|
|
|
1
|
-
# DhanHQ —
|
|
1
|
+
# DhanHQ — Ruby SDK & Client for Dhan API v2
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/DhanHQ)
|
|
4
4
|
[](https://github.com/shubhamtaywade82/dhanhq-client/actions/workflows/main.yml)
|
|
5
5
|
[](https://www.ruby-lang.org)
|
|
6
6
|
[](LICENSE.txt)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
**DhanHQ** is a production-grade **Ruby SDK for the Dhan API v2** — build algorithmic trading systems, market data pipelines, and portfolio management tools for Indian markets (NSE, BSE, MCX) with clean Ruby abstractions, resilient WebSocket streaming, typed models, dry-validation contracts, and safety-focused order workflows for Ruby on Rails and standalone Ruby applications.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
If you're looking for a Ruby gem for the Dhan trading API, this is built to be the default choice.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
- real-time market data streaming
|
|
14
|
-
- portfolio and order management
|
|
15
|
-
- Rails or standalone trading systems
|
|
12
|
+
## Quick Start
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
```ruby
|
|
15
|
+
# Gemfile
|
|
16
|
+
gem 'DhanHQ'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require 'dhan_hq'
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
DhanHQ.configure do |c|
|
|
23
|
+
c.client_id = ENV["DHAN_CLIENT_ID"]
|
|
24
|
+
c.access_token = ENV["DHAN_ACCESS_TOKEN"]
|
|
25
|
+
end
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- safety rails for live trading
|
|
27
|
+
# You're live — no manual HTTP, no JSON parsing
|
|
28
|
+
positions = DhanHQ::Models::Position.all
|
|
29
|
+
```
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
## Features
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
- **Typed models** for orders, positions, holdings, funds, and trades
|
|
34
|
+
- **WebSocket market feed** with auto-reconnect and exponential backoff
|
|
35
|
+
- **WebSocket order updates** — real-time execution events
|
|
36
|
+
- **Token lifecycle management** with automatic retry-on-401
|
|
37
|
+
- **dry-validation contracts** for every trading request
|
|
38
|
+
- **Rails integration** with ActionCable, config generators, and rake tasks
|
|
39
|
+
- **Safety rails** — validation before transport, no blind retries
|
|
40
|
+
- **Comprehensive docs** — 25+ guides covering auth, WebSocket, orders, super orders, TA, and testing
|
|
41
|
+
- **REST API** — orders, super orders, positions, holdings, funds, instruments, option chain, historical data, and more
|
|
29
42
|
|
|
30
43
|
```ruby
|
|
31
44
|
# Gemfile
|
|
@@ -62,7 +75,7 @@ positions = DhanHQ::Models::Position.all
|
|
|
62
75
|
|
|
63
76
|
## Start Here (Pick Your Use Case)
|
|
64
77
|
|
|
65
|
-
Pick the path that matches what you want to build:
|
|
78
|
+
Pick the path that matches what you want to build, or just read the [Quickstart](QUICKSTART.md) top to bottom:
|
|
66
79
|
|
|
67
80
|
- **Get live prices fast** → [Market Feed WebSocket](#market-feed-ticker--quote--full)
|
|
68
81
|
- **Place orders safely** → [Order Safety](#order-safety)
|
|
@@ -205,6 +218,34 @@ end
|
|
|
205
218
|
|
|
206
219
|
When the API returns 401, the client retries **once** with a fresh token from your provider.
|
|
207
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
|
+
|
|
208
249
|
> **Full details**: TOTP flows, partner mode, token endpoint bootstrap, auto-management — see [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md).
|
|
209
250
|
|
|
210
251
|
---
|
|
@@ -529,6 +570,10 @@ client.health
|
|
|
529
570
|
> 5,000 instruments per connection and 100 instruments per subscribe frame. Running several
|
|
530
571
|
> strategies in separate processes counts against the same limit — a 6th connection is refused.
|
|
531
572
|
|
|
573
|
+
If frames stop arriving or parsing fails and `healthy?`/logs alone aren't enough, set
|
|
574
|
+
`DHAN_WS_DEBUG=true` to hex-dump every raw inbound frame at debug level — see
|
|
575
|
+
[Troubleshooting](docs/TROUBLESHOOTING.md#websocket-frame-debugging).
|
|
576
|
+
|
|
532
577
|
### Cleanup
|
|
533
578
|
|
|
534
579
|
```ruby
|
|
@@ -704,7 +749,11 @@ sleep # keep the script alive
|
|
|
704
749
|
|
|
705
750
|
## Rails Integration
|
|
706
751
|
|
|
707
|
-
|
|
752
|
+
```bash
|
|
753
|
+
rails generate dhanhq:install
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Scaffolds `config/initializers/dhanhq.rb`, an order-placing service object, a Sidekiq market-feed worker, and an ActionCable channel in one command. For the full picture — service objects, ActionCable wiring, background workers, dynamic token providers — see the [Rails Integration Guide](docs/RAILS_INTEGRATION.md).
|
|
708
757
|
|
|
709
758
|
---
|
|
710
759
|
|
|
@@ -743,6 +792,7 @@ For search-driven discovery and onboarding content, see:
|
|
|
743
792
|
|
|
744
793
|
| Guide | What it covers |
|
|
745
794
|
| ----- | -------------- |
|
|
795
|
+
| [Quickstart](QUICKSTART.md) | The 5 most common tasks in under 50 lines |
|
|
746
796
|
| [Architecture](ARCHITECTURE.md) | Layering, dependency flow, design patterns, extension points |
|
|
747
797
|
| [Authentication](docs/AUTHENTICATION.md) | Token flows, TOTP, OAuth, auto-management |
|
|
748
798
|
| [Configuration Reference](docs/CONFIGURATION.md) | Full ENV matrix, logging, timeouts, available resources |
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -51,6 +51,7 @@ These change what a write request *does*, not just where it goes. All default to
|
|
|
51
51
|
| `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
52
|
| `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. |
|
|
53
53
|
| `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false` | `config.warn_on_ambiguous_write_failure` | `true` (on) | Logs a once-per-call-site deprecation notice when a non-bang write method (`Order.place`, `#modify`, …) reports failure as `nil`, `false`, or a `DhanHQ::ErrorObject` — these disagree today and unify on `ErrorObject` in 4.0.0. Use the `!` variant (`place!`, `#modify!`) to get a raised `DhanHQ::OrderError` instead of the ambiguous return value. |
|
|
54
|
+
| `DHAN_WS_DEBUG=true` | `config.ws_debug` | `false` | Logs every raw inbound WebSocket frame (market feed, order updates, market depth) as a hex dump at `debug` level before it's parsed. Off by default — high volume, and there's no encoding cost when disabled since the flag is checked first. Requires `DHAN_LOG_LEVEL=debug` (see [Logging](#logging)) to actually see the output. See [Troubleshooting](TROUBLESHOOTING.md#websocket-frame-debugging). |
|
|
54
55
|
|
|
55
56
|
## `.env` File Setup
|
|
56
57
|
|
|
@@ -105,6 +106,31 @@ end
|
|
|
105
106
|
- **`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`.
|
|
106
107
|
- **`on_token_expired`**: Optional callable invoked when a 401/token-expired triggers a **single retry** (only when `access_token_provider` is set).
|
|
107
108
|
|
|
109
|
+
## Multi-Instance Client Configuration
|
|
110
|
+
|
|
111
|
+
For multi-account or multi-tenant architectures, construct explicit `DhanHQ::Client` instances without depending on global singleton state:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# Direct configuration via keyword arguments
|
|
115
|
+
client = DhanHQ::Client.new(
|
|
116
|
+
client_id: "YOUR_CLIENT_ID",
|
|
117
|
+
access_token: "YOUR_ACCESS_TOKEN",
|
|
118
|
+
sandbox: false,
|
|
119
|
+
dry_run: false
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Or via an explicit Configuration object
|
|
123
|
+
custom_config = DhanHQ::Configuration.new do |c|
|
|
124
|
+
c.client_id = "TENANT_CLIENT_ID"
|
|
125
|
+
c.access_token = "TENANT_ACCESS_TOKEN"
|
|
126
|
+
c.dry_run = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
client = DhanHQ::Client.new(config: custom_config)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
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.).
|
|
133
|
+
|
|
108
134
|
For detailed authentication flows, see [AUTHENTICATION.md](AUTHENTICATION.md).
|
|
109
135
|
|
|
110
136
|
## Available Resources
|
data/docs/RAILS_INTEGRATION.md
CHANGED
|
@@ -21,6 +21,11 @@ bundle install
|
|
|
21
21
|
If you package the gem privately you can also point to a released version from
|
|
22
22
|
RubyGems.
|
|
23
23
|
|
|
24
|
+
**Fast path:** `rails generate dhanhq:install` scaffolds the initializer below plus
|
|
25
|
+
a sample order service, a Sidekiq market-feed worker, and an ActionCable channel
|
|
26
|
+
in one command. The rest of this guide covers what it generates and the options
|
|
27
|
+
beyond it (dynamic tokens, order-update streaming, scheduled jobs).
|
|
28
|
+
|
|
24
29
|
## 2. Configure credentials & initializer
|
|
25
30
|
|
|
26
31
|
Store the Dhan client id and access token using Rails credentials or ENV
|
|
@@ -173,6 +178,32 @@ The gem exposes models for positions, holdings, trades, funds, option chains,
|
|
|
173
178
|
historical bars, etc. Instantiate them the same way (`Model.all`, `.find`,
|
|
174
179
|
`.where`, `#save`).
|
|
175
180
|
|
|
181
|
+
### Placing orders in the background
|
|
182
|
+
|
|
183
|
+
For order placement from a controller action or another job, `DhanHQ::Jobs::PlaceOrderJob`
|
|
184
|
+
ships with the gem — no service object required:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
DhanHQ::Jobs::PlaceOrderJob.perform_later(
|
|
188
|
+
transaction_type: DhanHQ::Constants::TransactionType::BUY,
|
|
189
|
+
exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
|
|
190
|
+
product_type: DhanHQ::Constants::ProductType::INTRADAY,
|
|
191
|
+
order_type: DhanHQ::Constants::OrderType::LIMIT,
|
|
192
|
+
validity: DhanHQ::Constants::Validity::DAY,
|
|
193
|
+
security_id: "11536",
|
|
194
|
+
quantity: 5,
|
|
195
|
+
price: 1500.0
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
It calls `Order.place!` and uses ActiveJob's `discard_on` for `DhanHQ::OrderError` and
|
|
200
|
+
`DhanHQ::RiskViolation` — a rejection is logged, not silently retried. This matters because
|
|
201
|
+
DhanHQ order writes are **not idempotent**: most queue adapters (Sidekiq chief among them)
|
|
202
|
+
retry unhandled exceptions at the backend level by default, independent of ActiveJob's own
|
|
203
|
+
opt-in `retry_on`, and a timed-out `POST /v2/orders` retry can place a duplicate order. Don't
|
|
204
|
+
add your own `retry_on DhanHQ::OrderError` on top of this job — that would fight the
|
|
205
|
+
`discard_on` and reintroduce the exact risk it exists to prevent.
|
|
206
|
+
|
|
176
207
|
## 4. Centralise error handling
|
|
177
208
|
|
|
178
209
|
Wrap the gem's exceptions in a concern so Rails controllers and jobs return
|
|
@@ -209,21 +240,26 @@ ticks through ActionCable, Redis, or a database.
|
|
|
209
240
|
# app/workers/dhan/market_feed_worker.rb
|
|
210
241
|
class Dhan::MarketFeedWorker
|
|
211
242
|
include Sidekiq::Worker
|
|
243
|
+
sidekiq_options retry: false # DhanHQ::WS::Client already reconnects and re-subscribes on its own
|
|
212
244
|
|
|
213
|
-
def perform(mode =
|
|
214
|
-
client = DhanHQ::WS
|
|
215
|
-
|
|
216
|
-
client.on(:open) { Rails.logger.info('Dhan WS connected') }
|
|
217
|
-
client.on(:close) { Rails.logger.warn('Dhan WS closed; worker will retry') }
|
|
218
|
-
client.on(:error) { |err| Rails.logger.error("Dhan WS error: #{err}") }
|
|
219
|
-
|
|
220
|
-
client.on(:tick) do |tick|
|
|
245
|
+
def perform(mode = "quote", securities = [])
|
|
246
|
+
client = DhanHQ::WS.connect(mode: mode.to_sym) do |tick|
|
|
221
247
|
ActionCable.server.broadcast('market_feed', tick)
|
|
222
248
|
end
|
|
223
249
|
|
|
224
|
-
client.
|
|
225
|
-
client.
|
|
226
|
-
|
|
250
|
+
client.on(:reconnect) { |info| Rails.logger.warn("Dhan WS reconnect ##{info[:attempt]}") }
|
|
251
|
+
client.on(:error) { |message| Rails.logger.error("Dhan WS error: #{message}") }
|
|
252
|
+
|
|
253
|
+
securities.each { |segment, security_id| client.subscribe_one(segment: segment, security_id: security_id) }
|
|
254
|
+
|
|
255
|
+
# Client#start already spawned a background thread and returned -- there is
|
|
256
|
+
# no blocking wait! method. Hold the worker open for the life of the
|
|
257
|
+
# connection so Sidekiq doesn't consider the job "done" the instant the
|
|
258
|
+
# feed opens.
|
|
259
|
+
loop do
|
|
260
|
+
sleep 30
|
|
261
|
+
break unless client.connected?
|
|
262
|
+
end
|
|
227
263
|
end
|
|
228
264
|
end
|
|
229
265
|
```
|
|
@@ -255,22 +291,29 @@ Use the order-update WebSocket endpoint (configure `ws_order_url` and
|
|
|
255
291
|
# app/workers/dhan/order_updates_worker.rb
|
|
256
292
|
class Dhan::OrderUpdatesWorker
|
|
257
293
|
include Sidekiq::Worker
|
|
294
|
+
sidekiq_options retry: false
|
|
258
295
|
|
|
259
296
|
def perform
|
|
260
|
-
client = DhanHQ::WS::
|
|
261
|
-
|
|
262
|
-
client.on(:order_update) do |payload|
|
|
263
|
-
OrderStatusUpdater.call(payload)
|
|
297
|
+
client = DhanHQ::WS::Orders.connect do |order_update|
|
|
298
|
+
OrderStatusUpdater.call(order_update)
|
|
264
299
|
end
|
|
265
300
|
|
|
266
301
|
client.on(:error) { |err| Rails.logger.error("Dhan order WS error: #{err}") }
|
|
267
302
|
|
|
268
|
-
|
|
269
|
-
|
|
303
|
+
loop do
|
|
304
|
+
sleep 30
|
|
305
|
+
break unless client.connected?
|
|
306
|
+
end
|
|
270
307
|
end
|
|
271
308
|
end
|
|
272
309
|
```
|
|
273
310
|
|
|
311
|
+
`DhanHQ::WS::Orders::Client` also tracks state for you without any extra
|
|
312
|
+
wiring — `client.order_state(order_no)`, `client.orders_by_status(status)`,
|
|
313
|
+
`client.pending_orders`, and friends stay in sync as updates arrive, so
|
|
314
|
+
`OrderStatusUpdater` doesn't need to re-derive status transitions from raw
|
|
315
|
+
payloads.
|
|
316
|
+
|
|
274
317
|
Inside `OrderStatusUpdater` you can reconcile the payload with your local order
|
|
275
318
|
records, notify users via ActionCable or email, etc.
|
|
276
319
|
|
data/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -50,16 +50,30 @@ client = DhanHQ::WS.connect(mode: :ticker) { |tick| puts tick[:ltp] }
|
|
|
50
50
|
|
|
51
51
|
**Solution:**
|
|
52
52
|
- The client safely drops malformed frames and keeps the event loop alive.
|
|
53
|
-
-
|
|
53
|
+
- Turn on [WebSocket Frame Debugging](#websocket-frame-debugging) below to see the actual bytes that failed to parse.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## WebSocket Frame Debugging
|
|
58
|
+
|
|
59
|
+
**Symptom:** Ticks stopped arriving, or you're seeing "unknown feed kind" / parse errors and need to see what the server actually sent — a bug report of "got garbage data" is unactionable without the raw bytes.
|
|
60
|
+
|
|
61
|
+
**Solution:** Set `DHAN_WS_DEBUG=true` (or `config.ws_debug = true`) alongside debug-level logging. Every raw inbound frame — market feed, order updates, market depth — is logged as a hex dump before it's parsed:
|
|
54
62
|
|
|
55
63
|
```bash
|
|
64
|
+
export DHAN_WS_DEBUG=true
|
|
56
65
|
export DHAN_LOG_LEVEL=DEBUG
|
|
57
66
|
```
|
|
58
67
|
|
|
59
68
|
```ruby
|
|
69
|
+
DhanHQ.configure do |c|
|
|
70
|
+
c.ws_debug = true
|
|
71
|
+
end
|
|
60
72
|
DhanHQ.logger.level = Logger::DEBUG
|
|
61
73
|
```
|
|
62
74
|
|
|
75
|
+
This is off by default and high-volume when on — the flag is checked before any hex encoding work, so leaving it off costs nothing, but a live feed with `DHAN_WS_DEBUG=true` will log every single tick. Turn it off once you have what you need. See [Configuration Reference](CONFIGURATION.md#behavior-flags) for the full flag reference.
|
|
76
|
+
|
|
63
77
|
---
|
|
64
78
|
|
|
65
79
|
## Authentication Errors
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Backtest
|
|
5
|
+
# Aggregated outcome of a Runner run: the trade log, a per-bar equity
|
|
6
|
+
# curve, and derived summary statistics. Pure math over data Runner
|
|
7
|
+
# already computed — no I/O.
|
|
8
|
+
class Result
|
|
9
|
+
attr_reader :trades, :equity_curve, :initial_capital
|
|
10
|
+
|
|
11
|
+
# @param trades [Array<DhanHQ::Backtest::Trade>]
|
|
12
|
+
# @param equity_curve [Array<Float>] mark-to-market equity, one point per candle
|
|
13
|
+
# @param initial_capital [Float]
|
|
14
|
+
def initialize(trades:, equity_curve:, initial_capital:)
|
|
15
|
+
@trades = trades
|
|
16
|
+
@equity_curve = equity_curve
|
|
17
|
+
@initial_capital = initial_capital.to_f
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Float] equity after the last candle (or initial_capital if no candles ran)
|
|
21
|
+
def final_equity
|
|
22
|
+
equity_curve.last || initial_capital
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Float] total return over the run, as a percentage of initial capital
|
|
26
|
+
def total_return_pct
|
|
27
|
+
return 0.0 if initial_capital.zero?
|
|
28
|
+
|
|
29
|
+
((final_equity - initial_capital) / initial_capital) * 100
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Integer] number of completed round-trip trades
|
|
33
|
+
def num_trades
|
|
34
|
+
trades.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<DhanHQ::Backtest::Trade>] trades that closed profitably
|
|
38
|
+
def winning_trades
|
|
39
|
+
trades.select(&:win?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Float] percentage of trades that closed profitably
|
|
43
|
+
def win_rate
|
|
44
|
+
return 0.0 if trades.empty?
|
|
45
|
+
|
|
46
|
+
(winning_trades.size.to_f / trades.size) * 100
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Float] mean pnl across all trades
|
|
50
|
+
def avg_trade_pnl
|
|
51
|
+
return 0.0 if trades.empty?
|
|
52
|
+
|
|
53
|
+
trades.sum(&:pnl) / trades.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Float] largest peak-to-trough decline in the equity curve, as a percentage
|
|
57
|
+
def max_drawdown_pct
|
|
58
|
+
return 0.0 if equity_curve.empty?
|
|
59
|
+
|
|
60
|
+
peak = equity_curve.first
|
|
61
|
+
max_dd = 0.0
|
|
62
|
+
|
|
63
|
+
equity_curve.each do |equity|
|
|
64
|
+
peak = equity if equity > peak
|
|
65
|
+
next if peak.zero?
|
|
66
|
+
|
|
67
|
+
drawdown = ((peak - equity) / peak) * 100
|
|
68
|
+
max_dd = drawdown if drawdown > max_dd
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
max_dd
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Hash] rounded snapshot of the metrics above, for reporting
|
|
75
|
+
def summary
|
|
76
|
+
{
|
|
77
|
+
total_return_pct: total_return_pct.round(2),
|
|
78
|
+
num_trades: num_trades,
|
|
79
|
+
win_rate: win_rate.round(2),
|
|
80
|
+
avg_trade_pnl: avg_trade_pnl.round(2),
|
|
81
|
+
max_drawdown_pct: max_drawdown_pct.round(2),
|
|
82
|
+
final_equity: final_equity.round(2)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Backtest
|
|
5
|
+
# Replays a DhanHQ::Strategy::Base against historical OHLC candles and
|
|
6
|
+
# returns a Result.
|
|
7
|
+
#
|
|
8
|
+
# Both entries and exits fill at the *next* candle's open, never the
|
|
9
|
+
# signal candle's own price — evaluating a signal on a candle and then
|
|
10
|
+
# filling somewhere inside that same candle is look-ahead bias, since the
|
|
11
|
+
# fill price would have to come from before the candle closed (and so
|
|
12
|
+
# before the signal could have fired live). The one exception is the end
|
|
13
|
+
# of the dataset: an open position with no next candle to fill against is
|
|
14
|
+
# force-closed at the last candle's close.
|
|
15
|
+
#
|
|
16
|
+
# Only one position is held at a time, matching DhanHQ::Strategy::Base's
|
|
17
|
+
# single `@position` / entry-then-exit model — no pyramiding, no shorting.
|
|
18
|
+
class Runner
|
|
19
|
+
DEFAULT_QUANTITY = ->(equity, price) { price.positive? ? (equity / price).floor : 0 }
|
|
20
|
+
NO_FEES = ->(_trade_value) { 0.0 }
|
|
21
|
+
|
|
22
|
+
# @param strategy [DhanHQ::Strategy::Base]
|
|
23
|
+
# @param data [DhanHQ::MarketData::OHLCSeries]
|
|
24
|
+
# @param initial_capital [Float]
|
|
25
|
+
# @param quantity [#call, Integer] `->(equity, price) { ... }`, or a fixed integer
|
|
26
|
+
# @param fees [#call] `->(trade_value) { ... }`, charged once per leg (entry, exit)
|
|
27
|
+
# @param max_bars_held [Integer, nil] force-exit a position held this many bars or longer
|
|
28
|
+
def initialize(strategy:, data:, initial_capital:, quantity: DEFAULT_QUANTITY, fees: NO_FEES, max_bars_held: nil)
|
|
29
|
+
@strategy = strategy
|
|
30
|
+
@candles = data.respond_to?(:candles) ? data.candles : Array(data)
|
|
31
|
+
@initial_capital = initial_capital.to_f
|
|
32
|
+
@quantity = quantity.respond_to?(:call) ? quantity : ->(_equity, _price) { quantity }
|
|
33
|
+
@fees = fees
|
|
34
|
+
@max_bars_held = max_bars_held
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [DhanHQ::Backtest::Result]
|
|
38
|
+
def run
|
|
39
|
+
return Result.new(trades: [], equity_curve: [], initial_capital: @initial_capital) if @candles.empty?
|
|
40
|
+
|
|
41
|
+
equity = @initial_capital
|
|
42
|
+
equity_curve = []
|
|
43
|
+
trades = []
|
|
44
|
+
window_candles = []
|
|
45
|
+
open_trade = nil
|
|
46
|
+
entry_index = nil
|
|
47
|
+
|
|
48
|
+
@candles.each_with_index do |candle, index|
|
|
49
|
+
window_candles << candle
|
|
50
|
+
window = DhanHQ::MarketData::OHLCSeries.new(window_candles)
|
|
51
|
+
has_next = index < @candles.size - 1
|
|
52
|
+
|
|
53
|
+
if open_trade && has_next
|
|
54
|
+
trade = maybe_exit(open_trade, window, index, entry_index, equity, equity_curve, @candles[index + 1])
|
|
55
|
+
|
|
56
|
+
if trade
|
|
57
|
+
trades << trade
|
|
58
|
+
equity += trade.pnl
|
|
59
|
+
open_trade = nil
|
|
60
|
+
entry_index = nil
|
|
61
|
+
end
|
|
62
|
+
elsif open_trade.nil? && has_next
|
|
63
|
+
open_trade, entry_index = maybe_enter(window, @candles[index + 1], equity, index)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A position opened this very iteration (via maybe_enter) isn't filled until the
|
|
67
|
+
# *next* candle — mark-to-market must stay flat until index reaches entry_index,
|
|
68
|
+
# or the equity curve would price the position off a fill that hasn't happened yet.
|
|
69
|
+
filled = open_trade && index >= entry_index
|
|
70
|
+
equity_curve << mark_to_market(equity, filled ? open_trade : nil, candle.close)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if open_trade
|
|
74
|
+
last = @candles.last
|
|
75
|
+
trade = close_trade(open_trade, last.timestamp, last.close, :end_of_data)
|
|
76
|
+
trades << trade
|
|
77
|
+
equity += trade.pnl
|
|
78
|
+
equity_curve[-1] = equity
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Result.new(trades: trades, equity_curve: equity_curve, initial_capital: @initial_capital)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def maybe_enter(window, next_candle, equity, index)
|
|
87
|
+
signal = @strategy.evaluate_entry(window)
|
|
88
|
+
return [nil, nil] unless signal.buy?
|
|
89
|
+
|
|
90
|
+
qty = @quantity.call(equity, next_candle.open)
|
|
91
|
+
return [nil, nil] unless qty.positive?
|
|
92
|
+
|
|
93
|
+
[{ entry_time: next_candle.timestamp, entry_price: next_candle.open, quantity: qty }, index + 1]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def maybe_exit(open_trade, window, index, entry_index, equity, equity_curve, next_candle)
|
|
97
|
+
bars_held = index - entry_index
|
|
98
|
+
forced = @max_bars_held && bars_held >= @max_bars_held
|
|
99
|
+
drawdown = drawdown_pct(equity_curve, equity)
|
|
100
|
+
violations = @strategy.check_risks(equity: equity, position: open_trade, drawdown: drawdown)
|
|
101
|
+
signal = @strategy.evaluate_exit(window)
|
|
102
|
+
|
|
103
|
+
return unless forced || violations.any? || signal.sell?
|
|
104
|
+
|
|
105
|
+
reason = if forced
|
|
106
|
+
:max_bars_held
|
|
107
|
+
elsif violations.any?
|
|
108
|
+
:risk_violation
|
|
109
|
+
else
|
|
110
|
+
:signal
|
|
111
|
+
end
|
|
112
|
+
close_trade(open_trade, next_candle.timestamp, next_candle.open, reason)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def close_trade(open_trade, exit_time, exit_price, reason)
|
|
116
|
+
entry_value = open_trade[:entry_price] * open_trade[:quantity]
|
|
117
|
+
exit_value = exit_price * open_trade[:quantity]
|
|
118
|
+
|
|
119
|
+
Trade.new(
|
|
120
|
+
entry_time: open_trade[:entry_time],
|
|
121
|
+
entry_price: open_trade[:entry_price],
|
|
122
|
+
exit_time: exit_time,
|
|
123
|
+
exit_price: exit_price,
|
|
124
|
+
quantity: open_trade[:quantity],
|
|
125
|
+
exit_reason: reason,
|
|
126
|
+
fees: @fees.call(entry_value) + @fees.call(exit_value)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def mark_to_market(equity, open_trade, close_price)
|
|
131
|
+
return equity unless open_trade
|
|
132
|
+
|
|
133
|
+
equity + ((close_price - open_trade[:entry_price]) * open_trade[:quantity])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def drawdown_pct(equity_curve, current_equity)
|
|
137
|
+
peak = (equity_curve + [current_equity]).max
|
|
138
|
+
return 0.0 if peak.nil? || peak.zero?
|
|
139
|
+
|
|
140
|
+
[((peak - current_equity) / peak) * 100, 0.0].max
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|