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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 065ac08985790dc4172816430025c482f539b506445c84181f69bdb56d30f510
4
- data.tar.gz: 8f545f2ac8416dfadfe4fc0a5216b4203d06eaf05e1b1094d14ee571e84de342
3
+ metadata.gz: 8c2f0784210b7f48ccb056b8cc486ce5c3ef655f69a4bd2834a150b1d04a8bc7
4
+ data.tar.gz: a6628fc83e8ec61bd4ca4b0eb6147a67232156f98ab49196c0a81850f9b4ac59
5
5
  SHA512:
6
- metadata.gz: 27d821b96f67997c64f1a523c2a366736dabcea5175e621014e22c9b691f35835f008f3bacaefe6b26229d2de92a78d44719f1b5ebff3e288960f156f7192331
7
- data.tar.gz: 76558d36116bbc9c2b024aacc0c6cb1c30640b777cf2cd0832cb6d8da9b142fd6aca350318489a3d13f848136c1089bcd1ef014a2a975c8f66bde1e7c8131d10
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 — The Ruby SDK for Dhan API v2
1
+ # DhanHQ — Ruby SDK & Client for Dhan API v2
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/DhanHQ.svg)](https://rubygems.org/gems/DhanHQ)
4
4
  [![CI](https://github.com/shubhamtaywade82/dhanhq-client/actions/workflows/main.yml/badge.svg)](https://github.com/shubhamtaywade82/dhanhq-client/actions/workflows/main.yml)
5
5
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-ruby.svg)](https://www.ruby-lang.org)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.txt)
7
7
 
8
- Build trading systems in Ruby without fighting raw HTTP, fragile auth flows, or unreliable market streams.
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
- DhanHQ is a production-grade Ruby SDK for the [Dhan trading API](https://dhanhq.co/docs/v2/), designed for:
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
- - trading bots
13
- - real-time market data streaming
14
- - portfolio and order management
15
- - Rails or standalone trading systems
12
+ ## Quick Start
16
13
 
17
- If you're looking for a Ruby SDK for Dhan API, this is built to be the default choice.
14
+ ```ruby
15
+ # Gemfile
16
+ gem 'DhanHQ'
17
+ ```
18
+
19
+ ```ruby
20
+ require 'dhan_hq'
18
21
 
19
- Unlike thin wrappers, DhanHQ gives you:
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
- - typed models for orders, positions, holdings, and more
22
- - WebSocket clients with auto-reconnect and backoff
23
- - token lifecycle management with retry-on-401
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
- This is closer to trading infrastructure than a simple API client.
31
+ ## Features
27
32
 
28
- ## Install and Run in 60 Seconds
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
- Need initializers, service objects, ActionCable wiring, and background workers? See the [Rails Integration Guide](docs/RAILS_INTEGRATION.md).
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 |
@@ -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
@@ -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 = :quote, securities = [])
214
- client = DhanHQ::WS::Client.new(mode: mode.to_sym)
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.start
225
- client.subscribe(securities) if securities.any?
226
- client.wait! # blocks the worker thread while EventMachine runs
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::Client.new(kind: :order_updates)
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
- client.start
269
- client.wait!
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
 
@@ -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
- - Run with `DHAN_LOG_LEVEL=DEBUG` to inspect raw frames:
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