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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/GUIDE.md +16 -0
- data/README.md +93 -18
- data/docs/CONFIGURATION.md +39 -14
- data/docs/RAILS_INTEGRATION.md +60 -17
- data/docs/RELEASE_GUIDE.md +1 -1
- 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/concerns/bang_writes.rb +69 -0
- data/lib/DhanHQ/concerns/tracked_writes.rb +56 -0
- data/lib/DhanHQ/configuration.rb +35 -1
- data/lib/DhanHQ/core/base_model.rb +6 -13
- data/lib/DhanHQ/deprecation.rb +62 -0
- data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
- data/lib/DhanHQ/models/alert_order.rb +7 -0
- data/lib/DhanHQ/models/forever_order.rb +9 -0
- data/lib/DhanHQ/models/global_stocks/order.rb +9 -0
- data/lib/DhanHQ/models/iceberg_order.rb +9 -0
- data/lib/DhanHQ/models/multi_order.rb +7 -0
- data/lib/DhanHQ/models/order.rb +28 -0
- data/lib/DhanHQ/models/pnl_exit.rb +7 -0
- data/lib/DhanHQ/models/super_order.rb +9 -0
- data/lib/DhanHQ/models/twap_order.rb +9 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/write_result.rb +163 -0
- data/lib/DhanHQ/ws/base_connection.rb +1 -0
- data/lib/DhanHQ/ws/connection.rb +1 -0
- data/lib/DhanHQ/ws/orders/connection.rb +1 -0
- data/lib/DhanHQ/ws.rb +25 -0
- data/lib/dhan_hq.rb +5 -0
- 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
- data/skills/dhanhq-ruby/references/portfolio.md +15 -8
- metadata +30 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d97f7473f5bb7c4968750c3e21e5721a9c9ba1261e6e00d450818613edb74ec
|
|
4
|
+
data.tar.gz: 49464e4909ac0f52947fc86735b6b43bef6877f8ba0a1f796eb3b1c58c60bfde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 —
|
|
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
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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::
|
|
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
|
-
|
|
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 |
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
99
|
-
| ------------------------ |
|
|
100
|
-
| Orders | `DhanHQ::Models::Order`
|
|
101
|
-
| Trades | `DhanHQ::Models::Trade`
|
|
102
|
-
| Forever Orders | `DhanHQ::Models::ForeverOrder`
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
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.
|
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/RELEASE_GUIDE.md
CHANGED
|
@@ -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.
|
|
437
|
+
- **Required Ruby:** >= 3.2.0
|
|
438
438
|
- **License:** MIT
|
|
439
439
|
- **Homepage:** https://github.com/shubhamtaywade82/dhanhq-client
|
|
440
440
|
|
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
|