DhanHQ 3.2.1 → 3.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/GUIDE.md +16 -0
  4. data/README.md +93 -18
  5. data/docs/CONFIGURATION.md +39 -14
  6. data/docs/RAILS_INTEGRATION.md +60 -17
  7. data/docs/RELEASE_GUIDE.md +1 -1
  8. data/docs/TROUBLESHOOTING.md +15 -1
  9. data/lib/DhanHQ/backtest/result.rb +87 -0
  10. data/lib/DhanHQ/backtest/runner.rb +144 -0
  11. data/lib/DhanHQ/backtest/trade.rb +35 -0
  12. data/lib/DhanHQ/backtest.rb +24 -0
  13. data/lib/DhanHQ/concerns/bang_writes.rb +69 -0
  14. data/lib/DhanHQ/concerns/tracked_writes.rb +56 -0
  15. data/lib/DhanHQ/configuration.rb +35 -1
  16. data/lib/DhanHQ/core/base_model.rb +6 -13
  17. data/lib/DhanHQ/deprecation.rb +62 -0
  18. data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
  19. data/lib/DhanHQ/models/alert_order.rb +7 -0
  20. data/lib/DhanHQ/models/forever_order.rb +9 -0
  21. data/lib/DhanHQ/models/global_stocks/order.rb +9 -0
  22. data/lib/DhanHQ/models/iceberg_order.rb +9 -0
  23. data/lib/DhanHQ/models/multi_order.rb +7 -0
  24. data/lib/DhanHQ/models/order.rb +28 -0
  25. data/lib/DhanHQ/models/pnl_exit.rb +7 -0
  26. data/lib/DhanHQ/models/super_order.rb +9 -0
  27. data/lib/DhanHQ/models/twap_order.rb +9 -0
  28. data/lib/DhanHQ/version.rb +1 -1
  29. data/lib/DhanHQ/write_result.rb +163 -0
  30. data/lib/DhanHQ/ws/base_connection.rb +1 -0
  31. data/lib/DhanHQ/ws/connection.rb +1 -0
  32. data/lib/DhanHQ/ws/orders/connection.rb +1 -0
  33. data/lib/DhanHQ/ws.rb +25 -0
  34. data/lib/dhan_hq.rb +5 -0
  35. data/lib/generators/dhanhq/install/install_generator.rb +45 -0
  36. data/lib/generators/dhanhq/install/templates/initializer.rb +18 -0
  37. data/lib/generators/dhanhq/install/templates/market_feed_channel.rb +7 -0
  38. data/lib/generators/dhanhq/install/templates/market_feed_worker.rb +27 -0
  39. data/lib/generators/dhanhq/install/templates/place_order_service.rb +24 -0
  40. data/skills/dhanhq-ruby/references/portfolio.md +15 -8
  41. metadata +30 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 287ad8c77adb3623574c0ee14102cfc3e88695168f75e1550a25dbbff9eb8508
4
- data.tar.gz: a61b3f8e664f45f203b1afc3f840c9ed62a4e3c40e2f2d17fedc080ac682e029
3
+ metadata.gz: 6d97f7473f5bb7c4968750c3e21e5721a9c9ba1261e6e00d450818613edb74ec
4
+ data.tar.gz: 49464e4909ac0f52947fc86735b6b43bef6877f8ba0a1f796eb3b1c58c60bfde
5
5
  SHA512:
6
- metadata.gz: 2c9ad1a3df906b585b0308809fb75235cf475a23c4eaddfea3f699de2e577819a1b78315b60012b6c51bb6c398dcaa35daab31d9ae9452d81d84e3e76741f0a9
7
- data.tar.gz: 888e390276144b239a1bdd43afb9e77b58d21703587b898497b2426b8b78b47c7e03fd607bb3306fea49f9c2ae0e04a11ccb1b6968fc109fe61a8d2627b750a4
6
+ metadata.gz: 5a70be1d7aa2af222df63e8188af1b60e110431d73ae36317244693d974e46a3fcb84a9a49d9276fbadeea105c1124753129aa1c057d089924f0b71c7075f481
7
+ data.tar.gz: cb8a910ad46ae08b570eb9b19ac3a553de9a050e1b2a409d1414f3eb18d7eb762cb2fc62d6f2a5a72ad9014f726a1195541fa29ea80d749ba4c1dc21d6b0903f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,79 @@
1
+ ## [3.4.0] - 2026-08-15
2
+
3
+ ### Added
4
+
5
+ - **`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`).
6
+
7
+ 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.
8
+
9
+ - **`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.
10
+
11
+ - **`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.
12
+
13
+ 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.
14
+
15
+ - **`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.
16
+
17
+ - **`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.
18
+
19
+ ### Fixed
20
+
21
+ - **`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.
23
+ - **`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
+
25
+ ### Investigated, not changed
26
+
27
+ - **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`.
28
+
29
+ ## [3.3.0] - 2026-07-26
30
+
31
+ ### Added
32
+
33
+ - **Bang variants for every write method with a falsy failure contract** — `Order.place!`, `Order#modify!`/`#cancel!`/`#refresh!`, `SuperOrder.create!`/`#modify!`/`#cancel!`, `ForeverOrder.create!`, `IcebergOrder.create!`, `TwapOrder.create!`, `AlertOrder.create!`/`.modify!`, `PnlExit.configure!`/`.stop!`, `MultiOrder.place!`, `GlobalStocks::Order.place!`/`#modify!`/`#cancel!`.
34
+
35
+ Each raises `DhanHQ::OrderError` — which descends from `DhanHQ::Error`, so an existing `rescue DhanHQ::Error` handler still catches it — carrying whatever diagnostics the failure held. The non-bang methods are **untouched**: they return exactly what they returned before, so no existing caller changes behaviour.
36
+
37
+ This is step one of unifying the write return contracts. Today those contracts disagree: depending on the class and the failure, a rejected write comes back as `nil`, as `false`, or as a `DhanHQ::ErrorObject`, and `AlertOrder.modify` can return either of the first two from the same method. A caller cannot write one error branch. Unifying them outright would be a silent breaking change for dependent applications, because `nil` and `false` are falsy while `ErrorObject` is truthy — every `if result` failure branch in a dependent would quietly invert. So the migration is staged:
38
+
39
+ 1. **This release** — additive bang variants. Opt in per call site.
40
+ 2. **This release** — log a deprecation whenever a non-bang write returns a falsy failure, to find the remaining call sites from dependents' logs.
41
+ 3. **4.0.0** — non-bang methods return `ErrorObject` uniformly, gated on step 2 going quiet.
42
+
43
+ - **Deprecation notices for ambiguous write failures** (step two). When a non-bang write returns `nil`, `false` or a `DhanHQ::ErrorObject`, the SDK now logs once per call site:
44
+
45
+ ```
46
+ [DhanHQ] DEPRECATION: DhanHQ::Models::Order.place reported failure as nil. Write
47
+ methods return nil, false or a DhanHQ::ErrorObject inconsistently today and will all
48
+ return DhanHQ::ErrorObject in 4.0.0, which is truthy — an `if result` failure branch
49
+ will invert. Use DhanHQ::Models::Order.place! to get a DhanHQ::OrderError instead...
50
+ ```
51
+
52
+ The point is to surface the remaining call sites from dependent applications' logs before 4.0.0 changes any return value. Once per call site per process, not once per failure — a session that rejects hundreds of orders would otherwise produce noise that gets filtered out, defeating the purpose.
53
+
54
+ This layer only observes: it never alters a return value and never raises. It is silent on success, silent when reached through a bang variant (those callers have already migrated), and silent when you set `config.warn_on_ambiguous_write_failure = false` / `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false`. Defaults to on, because a notice nobody sees finds nothing.
55
+
56
+ - **`DhanHQ::Concerns::TrackedWrites`** — installs the observation. Uses `prepend` rather than `include`, since the write methods are defined directly on the model classes and an included module would sit behind them in the ancestor chain and never be reached.
57
+ - **`DhanHQ::Deprecation`** — once-per-key notice registry, thread-safe, with `warned_keys` and `reset!` for tests.
58
+ - **`DhanHQ::WriteResult`** — puts the knowledge of what a write failure looks like in one place (`failure?`, `success?`, `unwrap!`). `BaseModel#save!` now uses it instead of duplicating the same three-way check inline.
59
+ - **`DhanHQ::Concerns::BangWrites`** — generates the bang variants by delegating to their non-bang counterparts, so the two cannot drift: no duplicated request building, validation or logging. Generated as a module, so a hand-written `place!` can still override and call `super`.
60
+
61
+ ### Fixed
62
+
63
+ - **`DhanHQ::MCP` was unreachable on a bare `require "dhan_hq"`.** Same failure as the `DhanHQ::AI` fix earlier in this release: `mcp.rb` defines `DhanHQ::MCP` while Zeitwerk's inflector expected `DhanHQ::Mcp` from the filename, so `DhanHQ::MCP::Server` raised `NameError` unless something had already loaded the file by hand — which only `exe/dhanhq-mcp` and `lib/dhan_hq/mcp.rb` did. Found the same way the `AI` bug was: cross-referencing every `DhanHQ::` constant named in the docs against what actually resolves. Guarded by a new `spec/dhan_hq/zeitwerk_autoload_spec.rb`, which shells out to a subprocess so the check can't be satisfied by another spec having already loaded the file by hand.
64
+ - **`lib/DhanHQ/configuration.rb`'s doc comment referenced `DhanHQ::Models::Order.find_by_correlation_id`, which does not exist** — the real method is `.find_by_correlation` (no `_id` suffix). Caught during the same audit.
65
+ - A documentation pass across the gem ahead of this release, cross-checking every code example and referenced class/method against the actual codebase:
66
+ - `docs/CONFIGURATION.md`'s resource table named `DhanHQ::Models::Fund` (real class: `Funds`) and `DhanHQ::Models::Ledger` (real class: `LedgerEntry`), and listed `fund_limit`/`margin_calculator` as `Funds` methods — neither exists; the real methods are `Funds.fetch`/`.balance` and `Margin.calculate`/`.calculate_multi`. The table now also covers the resources 3.2.0/3.3.0 added (Iceberg/TWAP/Alert orders, Multi Order, P&L Exit, eDIS, Global Stocks) and lists the `!` write variants.
67
+ - `docs/CONFIGURATION.md` was missing `LIVE_TRADING`, `DHAN_DRY_RUN`, `DHAN_RETRY_WRITES`, `DHAN_AUTO_CORRELATION_ID`, `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE` and `DHAN_MARKET_DEPTH_LEVEL` entirely, and documented `DHAN_LOG_LEVEL` as if the library read it automatically — it doesn't; only the existing `## Logging` snippet wires it up.
68
+ - `skills/dhanhq-ruby/references/portfolio.md`'s eDIS section had the class name miscased (`EDIS` vs. `Edis`), called a nonexistent `.open_browser_for_tpin`, called `.inquiry` instead of `.inquire`, and accessed the (Hash) result via method calls instead of keys.
69
+ - `README.md` called `DhanHQ::Models::Fund.balance` — same class-name typo as above.
70
+ - `docs/RELEASE_GUIDE.md` claimed `Required Ruby: >= 3.1.0`; the gemspec has required `>= 3.2.0` since 3.0.0.
71
+ - `GUIDE.md` had no mention of the bang write variants added in this release; added a short section pointing to the README's fuller treatment.
72
+
73
+ ### Changed
74
+
75
+ - `BaseModel#save!`'s exception message is now `"<Class>#save failed: <details>"` rather than `"Failed to save the record: <details>"`. The exception class is unchanged (`DhanHQ::Error`), and nothing in the gem, specs or docs asserted the old text.
76
+
1
77
  ## [3.2.1] - 2026-07-26
2
78
 
3
79
  ### Fixed
data/GUIDE.md CHANGED
@@ -186,6 +186,22 @@ DhanHQ::Contracts::ModifyOrderContract.new.call(params).success?
186
186
  DhanHQ::Models::Order.resource.update("123", params)
187
187
  ```
188
188
 
189
+ ### Explicit Failures: Bang Variants
190
+
191
+ `place`, `#modify`, `#cancel` and their equivalents across every write model do not agree on
192
+ how they report failure — depending on the class, a rejected write comes back as `nil`,
193
+ `false`, or a `DhanHQ::ErrorObject`. Each has a `!` variant that raises `DhanHQ::OrderError`
194
+ instead, so one `rescue` handles every failure the same way:
195
+
196
+ ```ruby
197
+ order = DhanHQ::Models::Order.place!(params) # raises DhanHQ::OrderError on failure
198
+ order.cancel! # raises if the exchange did not cancel
199
+ ```
200
+
201
+ The non-bang methods are unchanged — adopting the bang variant is opt-in, per call site.
202
+ See the [README](README.md#explicit-failures-bang-variants) for the full list of `!` methods
203
+ and the deprecation notice that flags remaining non-bang call sites ahead of 4.0.0.
204
+
189
205
  ### Slicing Orders
190
206
 
191
207
  Use the same fields as placement, but the contract allows additional validity options (`GTC`, `GTD`). The model helper accepts snake_case parameters and handles camelCase conversion as part of validation:
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
18
 
19
- Unlike thin wrappers, DhanHQ gives you:
19
+ ```ruby
20
+ require 'dhan_hq'
21
+
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)
@@ -314,6 +327,59 @@ risk:
314
327
  DhanHQ.configure { |config| config.retry_non_idempotent_writes = true }
315
328
  ```
316
329
 
330
+ ### Explicit Failures: Bang Variants
331
+
332
+ Every write method has a `!` variant that raises instead of returning a falsy value:
333
+
334
+ ```ruby
335
+ order = DhanHQ::Models::Order.place!(params) # raises DhanHQ::OrderError on failure
336
+ order.cancel! # raises if the exchange did not cancel
337
+ ```
338
+
339
+ This exists because the non-bang methods do not agree on how to report failure — some
340
+ return `nil`, some `false`, some a `DhanHQ::ErrorObject` — so a caller cannot write one
341
+ error branch:
342
+
343
+ ```ruby
344
+ # Before: which falsy thing came back depends on which method and which failure
345
+ order = DhanHQ::Models::Order.place(params)
346
+ return unless order # nil on rejection
347
+
348
+ result = DhanHQ::Models::MultiOrder.place(legs)
349
+ return if result.is_a?(DhanHQ::ErrorObject) # truthy! `unless result` would not catch this
350
+ ```
351
+
352
+ `DhanHQ::OrderError` descends from `DhanHQ::Error`, so an existing `rescue DhanHQ::Error`
353
+ keeps working. The non-bang methods are unchanged, so adopting this is per call site:
354
+
355
+ ```ruby
356
+ begin
357
+ DhanHQ::Models::Order.place!(params)
358
+ rescue DhanHQ::OrderError => e
359
+ logger.error(e.message) # carries the API's diagnostics
360
+ end
361
+ ```
362
+
363
+ When a non-bang write reports failure, the SDK logs once per call site to help you find
364
+ what needs migrating before 4.0.0 changes the return value:
365
+
366
+ ```
367
+ [DhanHQ] DEPRECATION: DhanHQ::Models::Order.place reported failure as nil. ...
368
+ Use DhanHQ::Models::Order.place! to get a DhanHQ::OrderError instead, or set
369
+ config.warn_on_ambiguous_write_failure = false to silence this.
370
+ ```
371
+
372
+ It fires once per call site per process, never alters a return value, never raises, and
373
+ goes quiet once you switch that site to the bang variant. To silence it entirely:
374
+
375
+ ```ruby
376
+ DhanHQ.configure { |c| c.warn_on_ambiguous_write_failure = false } # or DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false
377
+ ```
378
+
379
+ Available on `Order`, `SuperOrder`, `ForeverOrder`, `IcebergOrder`, `TwapOrder`,
380
+ `AlertOrder`, `PnlExit`, `MultiOrder` and `GlobalStocks::Order`. The non-bang methods are
381
+ planned to converge on `ErrorObject` in 4.0.0 — see the CHANGELOG for the staged plan.
382
+
317
383
  ### Order Audit Logging
318
384
 
319
385
  Every order attempt (place, modify, slice) automatically logs a structured JSON line at WARN level:
@@ -374,7 +440,7 @@ order.cancel
374
440
  ```ruby
375
441
  DhanHQ::Models::Position.all
376
442
  DhanHQ::Models::Holding.all
377
- DhanHQ::Models::Fund.balance
443
+ DhanHQ::Models::Funds.balance
378
444
  ```
379
445
 
380
446
  ### Historical Data
@@ -476,6 +542,10 @@ client.health
476
542
  > 5,000 instruments per connection and 100 instruments per subscribe frame. Running several
477
543
  > strategies in separate processes counts against the same limit — a 6th connection is refused.
478
544
 
545
+ If frames stop arriving or parsing fails and `healthy?`/logs alone aren't enough, set
546
+ `DHAN_WS_DEBUG=true` to hex-dump every raw inbound frame at debug level — see
547
+ [Troubleshooting](docs/TROUBLESHOOTING.md#websocket-frame-debugging).
548
+
479
549
  ### Cleanup
480
550
 
481
551
  ```ruby
@@ -651,7 +721,11 @@ sleep # keep the script alive
651
721
 
652
722
  ## Rails Integration
653
723
 
654
- Need initializers, service objects, ActionCable wiring, and background workers? See the [Rails Integration Guide](docs/RAILS_INTEGRATION.md).
724
+ ```bash
725
+ rails generate dhanhq:install
726
+ ```
727
+
728
+ 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).
655
729
 
656
730
  ---
657
731
 
@@ -690,6 +764,7 @@ For search-driven discovery and onboarding content, see:
690
764
 
691
765
  | Guide | What it covers |
692
766
  | ----- | -------------- |
767
+ | [Quickstart](QUICKSTART.md) | The 5 most common tasks in under 50 lines |
693
768
  | [Architecture](ARCHITECTURE.md) | Layering, dependency flow, design patterns, extension points |
694
769
  | [Authentication](docs/AUTHENTICATION.md) | Token flows, TOTP, OAuth, auto-management |
695
770
  | [Configuration Reference](docs/CONFIGURATION.md) | Full ENV matrix, logging, timeouts, available resources |
@@ -24,7 +24,6 @@ Set these _before_ calling `configure_with_env` to override defaults:
24
24
 
25
25
  | Variable | Default | Description |
26
26
  | -------------------------------- | ---------- | ------------------------------------------------------- |
27
- | `DHAN_LOG_LEVEL` | `INFO` | Logger verbosity (`DEBUG`, `INFO`, `WARN`, `ERROR`) |
28
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). |
29
28
  | `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. |
30
29
  | `DHAN_WS_VERSION` | latest | Pin WebSocket connections to a specific API version |
@@ -32,12 +31,28 @@ Set these _before_ calling `configure_with_env` to override defaults:
32
31
  | `DHAN_WS_USER_TYPE` | `SELF` | Switch between `SELF` and `PARTNER` streaming modes |
33
32
  | `DHAN_PARTNER_ID` | — | Required when `DHAN_WS_USER_TYPE=PARTNER` |
34
33
  | `DHAN_PARTNER_SECRET` | — | Required when `DHAN_WS_USER_TYPE=PARTNER` |
34
+ | `DHAN_MARKET_DEPTH_LEVEL` | `20` | WebSocket market depth levels to subscribe to |
35
35
  | `DHAN_CONNECT_TIMEOUT` | `10` | Connection timeout in seconds |
36
36
  | `DHAN_READ_TIMEOUT` | `30` | Read timeout in seconds |
37
37
  | `DHAN_WRITE_TIMEOUT` | `30` | Write timeout in seconds |
38
38
  | `DHAN_WS_MAX_TRACKED_ORDERS` | `10000` | Maximum orders to track in WebSocket |
39
39
  | `DHAN_WS_MAX_ORDER_AGE` | `604800` | Maximum order age in seconds before cleanup (7 days) |
40
40
 
41
+ `DHAN_LOG_LEVEL` is **not** read automatically — see [Logging](#logging) below for the one-line snippet that wires it up.
42
+
43
+ ## Behavior Flags
44
+
45
+ These change what a write request *does*, not just where it goes. All default to the safe/conservative behavior and are off unless set.
46
+
47
+ | Variable | Config attribute | Default | Effect |
48
+ | ------------------------------------- | ------------------------------------ | ------- | ------ |
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. |
50
+ | `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
+ | `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
+ | `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
+ | `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). |
55
+
41
56
  ## `.env` File Setup
42
57
 
43
58
  Create a `.env` file in your project root:
@@ -95,16 +110,26 @@ For detailed authentication flows, see [AUTHENTICATION.md](AUTHENTICATION.md).
95
110
 
96
111
  ## Available Resources
97
112
 
98
- | Resource | Model | Actions |
99
- | ------------------------ | -------------------------------------- | --------------------------------------------------- |
100
- | Orders | `DhanHQ::Models::Order` | `find`, `all`, `where`, `place`, `update`, `cancel` |
101
- | Trades | `DhanHQ::Models::Trade` | `all`, `find_by_order_id` |
102
- | Forever Orders | `DhanHQ::Models::ForeverOrder` | `create`, `find`, `modify`, `cancel`, `all` |
103
- | Holdings | `DhanHQ::Models::Holding` | `all` |
104
- | Positions | `DhanHQ::Models::Position` | `all`, `find`, `exit!` |
105
- | Funds & Margin | `DhanHQ::Models::Fund` | `fund_limit`, `margin_calculator` |
106
- | Ledger | `DhanHQ::Models::Ledger` | `all` |
107
- | Market Feeds | `DhanHQ::Models::MarketFeed` | `ltp`, `ohlc`, `quote` |
108
- | Historical Data (Charts) | `DhanHQ::Models::HistoricalData` | `daily`, `intraday` |
109
- | Option Chain | `DhanHQ::Models::OptionChain` | `fetch`, `fetch_expiry_list` |
110
- | Super Orders | `DhanHQ::Models::SuperOrder` | `create`, `modify`, `cancel`, `all` |
113
+ | Resource | Model | Actions |
114
+ | ------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------ |
115
+ | Orders | `DhanHQ::Models::Order` | `place`, `find`, `all`, `where`, `#modify`, `#cancel`, `#destroy` (`!` variants raise) |
116
+ | Trades | `DhanHQ::Models::Trade` | `all`, `find_by_order_id` |
117
+ | Forever Orders | `DhanHQ::Models::ForeverOrder` | `create`, `find`, `all`, `#modify`, `#cancel` |
118
+ | Iceberg Orders | `DhanHQ::Models::IcebergOrder` | `create`, `find`, `all`, `#modify`, `#cancel` |
119
+ | TWAP Orders | `DhanHQ::Models::TwapOrder` | `create`, `find`, `all`, `#modify`, `#cancel` |
120
+ | Alert Orders | `DhanHQ::Models::AlertOrder` | `create`, `find`, `all`, `modify(alert_id, params)`, `#destroy` |
121
+ | Super Orders | `DhanHQ::Models::SuperOrder` | `create`, `all`, `#modify`, `#cancel(leg_name)` |
122
+ | Multi Order (basket) | `DhanHQ::Models::MultiOrder` | `place(orders, dhan_client_id:)` — up to 15 legs |
123
+ | P&L Exit | `DhanHQ::Models::PnlExit` | `configure`, `stop`, `status` |
124
+ | Holdings | `DhanHQ::Models::Holding` | `all` |
125
+ | Positions | `DhanHQ::Models::Position` | `all`, `active`, `#convert(params)`, `.exit_all!` |
126
+ | Funds | `DhanHQ::Models::Funds` | `fetch`, `balance` |
127
+ | Margin Calculator | `DhanHQ::Models::Margin` | `calculate(params)`, `calculate_multi(params)` |
128
+ | Ledger | `DhanHQ::Models::LedgerEntry` | `all(from_date:, to_date:)` |
129
+ | eDIS | `DhanHQ::Models::Edis` | `generate_tpin`, `generate_form`, `generate_bulk_form`, `inquire(isin:)` |
130
+ | Market Feeds | `DhanHQ::Models::MarketFeed` | `ltp`, `ohlc`, `quote` |
131
+ | Historical Data (Charts) | `DhanHQ::Models::HistoricalData` | `daily`, `intraday` |
132
+ | Option Chain | `DhanHQ::Models::OptionChain` | `fetch`, `fetch_expiry_list` |
133
+ | Global Stocks Orders | `DhanHQ::Models::GlobalStocks::Order` | `place`, `find`, `all`, `#cancel` — see [ARCHITECTURE.md](../ARCHITECTURE.md) |
134
+
135
+ `#method` denotes an instance method (called on a fetched or created record); everything else is a class method. Every write method above also has a `!` variant (`place!`, `#modify!`, `#cancel!`, …) that raises `DhanHQ::OrderError` instead of returning a falsy value or an `ErrorObject` — see the [CHANGELOG](../CHANGELOG.md) for the write-return-contract migration this is part of.
@@ -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
 
@@ -434,7 +434,7 @@ git push origin main v2.1.12
434
434
 
435
435
  - **Gem Name:** DhanHQ
436
436
  - **Current Version:** Check `lib/DhanHQ/version.rb`
437
- - **Required Ruby:** >= 3.1.0
437
+ - **Required Ruby:** >= 3.2.0
438
438
  - **License:** MIT
439
439
  - **Homepage:** https://github.com/shubhamtaywade82/dhanhq-client
440
440
 
@@ -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