DhanHQ 3.3.0 → 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 +28 -0
- data/README.md +39 -17
- data/docs/CONFIGURATION.md +1 -0
- data/docs/RAILS_INTEGRATION.md +60 -17
- data/docs/TROUBLESHOOTING.md +15 -1
- data/lib/DhanHQ/backtest/result.rb +87 -0
- data/lib/DhanHQ/backtest/runner.rb +144 -0
- data/lib/DhanHQ/backtest/trade.rb +35 -0
- data/lib/DhanHQ/backtest.rb +24 -0
- data/lib/DhanHQ/configuration.rb +15 -0
- data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
- data/lib/DhanHQ/version.rb +1 -1
- 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/generators/dhanhq/install/install_generator.rb +45 -0
- data/lib/generators/dhanhq/install/templates/initializer.rb +18 -0
- data/lib/generators/dhanhq/install/templates/market_feed_channel.rb +7 -0
- data/lib/generators/dhanhq/install/templates/market_feed_worker.rb +27 -0
- data/lib/generators/dhanhq/install/templates/place_order_service.rb +24 -0
- metadata +26 -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,31 @@
|
|
|
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
|
+
|
|
1
29
|
## [3.3.0] - 2026-07-26
|
|
2
30
|
|
|
3
31
|
### Added
|
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)
|
|
@@ -529,6 +542,10 @@ client.health
|
|
|
529
542
|
> 5,000 instruments per connection and 100 instruments per subscribe frame. Running several
|
|
530
543
|
> strategies in separate processes counts against the same limit — a 6th connection is refused.
|
|
531
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
|
+
|
|
532
549
|
### Cleanup
|
|
533
550
|
|
|
534
551
|
```ruby
|
|
@@ -704,7 +721,11 @@ sleep # keep the script alive
|
|
|
704
721
|
|
|
705
722
|
## Rails Integration
|
|
706
723
|
|
|
707
|
-
|
|
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).
|
|
708
729
|
|
|
709
730
|
---
|
|
710
731
|
|
|
@@ -743,6 +764,7 @@ For search-driven discovery and onboarding content, see:
|
|
|
743
764
|
|
|
744
765
|
| Guide | What it covers |
|
|
745
766
|
| ----- | -------------- |
|
|
767
|
+
| [Quickstart](QUICKSTART.md) | The 5 most common tasks in under 50 lines |
|
|
746
768
|
| [Architecture](ARCHITECTURE.md) | Layering, dependency flow, design patterns, extension points |
|
|
747
769
|
| [Authentication](docs/AUTHENTICATION.md) | Token flows, TOTP, OAuth, auto-management |
|
|
748
770
|
| [Configuration Reference](docs/CONFIGURATION.md) | Full ENV matrix, logging, timeouts, available resources |
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -51,6 +51,7 @@ These change what a write request *does*, not just where it goes. All default to
|
|
|
51
51
|
| `DHAN_RETRY_WRITES=true` | `config.retry_non_idempotent_writes` | `false` | Auto-retries a non-idempotent write (order placement, modify, cancel) after a transient failure (429, 5xx, timeout). Off by default because the API has no idempotency key — a timed-out POST may have already reached the exchange, and retrying it can place a duplicate order. |
|
|
52
52
|
| `DHAN_AUTO_CORRELATION_ID=true` | `config.auto_correlation_id` | `false` | Fills in a `correlationId` (`dhq-<hex>`) on order placements that lack one, so a timed-out placement can be reconciled via `GET /v2/orders/external/{correlation-id}`. Off by default because it changes the request body; an explicit correlation id is always preserved. |
|
|
53
53
|
| `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false` | `config.warn_on_ambiguous_write_failure` | `true` (on) | Logs a once-per-call-site deprecation notice when a non-bang write method (`Order.place`, `#modify`, …) reports failure as `nil`, `false`, or a `DhanHQ::ErrorObject` — these disagree today and unify on `ErrorObject` in 4.0.0. Use the `!` variant (`place!`, `#modify!`) to get a raised `DhanHQ::OrderError` instead of the ambiguous return value. |
|
|
54
|
+
| `DHAN_WS_DEBUG=true` | `config.ws_debug` | `false` | Logs every raw inbound WebSocket frame (market feed, order updates, market depth) as a hex dump at `debug` level before it's parsed. Off by default — high volume, and there's no encoding cost when disabled since the flag is checked first. Requires `DHAN_LOG_LEVEL=debug` (see [Logging](#logging)) to actually see the output. See [Troubleshooting](TROUBLESHOOTING.md#websocket-frame-debugging). |
|
|
54
55
|
|
|
55
56
|
## `.env` File Setup
|
|
56
57
|
|
data/docs/RAILS_INTEGRATION.md
CHANGED
|
@@ -21,6 +21,11 @@ bundle install
|
|
|
21
21
|
If you package the gem privately you can also point to a released version from
|
|
22
22
|
RubyGems.
|
|
23
23
|
|
|
24
|
+
**Fast path:** `rails generate dhanhq:install` scaffolds the initializer below plus
|
|
25
|
+
a sample order service, a Sidekiq market-feed worker, and an ActionCable channel
|
|
26
|
+
in one command. The rest of this guide covers what it generates and the options
|
|
27
|
+
beyond it (dynamic tokens, order-update streaming, scheduled jobs).
|
|
28
|
+
|
|
24
29
|
## 2. Configure credentials & initializer
|
|
25
30
|
|
|
26
31
|
Store the Dhan client id and access token using Rails credentials or ENV
|
|
@@ -173,6 +178,32 @@ The gem exposes models for positions, holdings, trades, funds, option chains,
|
|
|
173
178
|
historical bars, etc. Instantiate them the same way (`Model.all`, `.find`,
|
|
174
179
|
`.where`, `#save`).
|
|
175
180
|
|
|
181
|
+
### Placing orders in the background
|
|
182
|
+
|
|
183
|
+
For order placement from a controller action or another job, `DhanHQ::Jobs::PlaceOrderJob`
|
|
184
|
+
ships with the gem — no service object required:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
DhanHQ::Jobs::PlaceOrderJob.perform_later(
|
|
188
|
+
transaction_type: DhanHQ::Constants::TransactionType::BUY,
|
|
189
|
+
exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
|
|
190
|
+
product_type: DhanHQ::Constants::ProductType::INTRADAY,
|
|
191
|
+
order_type: DhanHQ::Constants::OrderType::LIMIT,
|
|
192
|
+
validity: DhanHQ::Constants::Validity::DAY,
|
|
193
|
+
security_id: "11536",
|
|
194
|
+
quantity: 5,
|
|
195
|
+
price: 1500.0
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
It calls `Order.place!` and uses ActiveJob's `discard_on` for `DhanHQ::OrderError` and
|
|
200
|
+
`DhanHQ::RiskViolation` — a rejection is logged, not silently retried. This matters because
|
|
201
|
+
DhanHQ order writes are **not idempotent**: most queue adapters (Sidekiq chief among them)
|
|
202
|
+
retry unhandled exceptions at the backend level by default, independent of ActiveJob's own
|
|
203
|
+
opt-in `retry_on`, and a timed-out `POST /v2/orders` retry can place a duplicate order. Don't
|
|
204
|
+
add your own `retry_on DhanHQ::OrderError` on top of this job — that would fight the
|
|
205
|
+
`discard_on` and reintroduce the exact risk it exists to prevent.
|
|
206
|
+
|
|
176
207
|
## 4. Centralise error handling
|
|
177
208
|
|
|
178
209
|
Wrap the gem's exceptions in a concern so Rails controllers and jobs return
|
|
@@ -209,21 +240,26 @@ ticks through ActionCable, Redis, or a database.
|
|
|
209
240
|
# app/workers/dhan/market_feed_worker.rb
|
|
210
241
|
class Dhan::MarketFeedWorker
|
|
211
242
|
include Sidekiq::Worker
|
|
243
|
+
sidekiq_options retry: false # DhanHQ::WS::Client already reconnects and re-subscribes on its own
|
|
212
244
|
|
|
213
|
-
def perform(mode =
|
|
214
|
-
client = DhanHQ::WS
|
|
215
|
-
|
|
216
|
-
client.on(:open) { Rails.logger.info('Dhan WS connected') }
|
|
217
|
-
client.on(:close) { Rails.logger.warn('Dhan WS closed; worker will retry') }
|
|
218
|
-
client.on(:error) { |err| Rails.logger.error("Dhan WS error: #{err}") }
|
|
219
|
-
|
|
220
|
-
client.on(:tick) do |tick|
|
|
245
|
+
def perform(mode = "quote", securities = [])
|
|
246
|
+
client = DhanHQ::WS.connect(mode: mode.to_sym) do |tick|
|
|
221
247
|
ActionCable.server.broadcast('market_feed', tick)
|
|
222
248
|
end
|
|
223
249
|
|
|
224
|
-
client.
|
|
225
|
-
client.
|
|
226
|
-
|
|
250
|
+
client.on(:reconnect) { |info| Rails.logger.warn("Dhan WS reconnect ##{info[:attempt]}") }
|
|
251
|
+
client.on(:error) { |message| Rails.logger.error("Dhan WS error: #{message}") }
|
|
252
|
+
|
|
253
|
+
securities.each { |segment, security_id| client.subscribe_one(segment: segment, security_id: security_id) }
|
|
254
|
+
|
|
255
|
+
# Client#start already spawned a background thread and returned -- there is
|
|
256
|
+
# no blocking wait! method. Hold the worker open for the life of the
|
|
257
|
+
# connection so Sidekiq doesn't consider the job "done" the instant the
|
|
258
|
+
# feed opens.
|
|
259
|
+
loop do
|
|
260
|
+
sleep 30
|
|
261
|
+
break unless client.connected?
|
|
262
|
+
end
|
|
227
263
|
end
|
|
228
264
|
end
|
|
229
265
|
```
|
|
@@ -255,22 +291,29 @@ Use the order-update WebSocket endpoint (configure `ws_order_url` and
|
|
|
255
291
|
# app/workers/dhan/order_updates_worker.rb
|
|
256
292
|
class Dhan::OrderUpdatesWorker
|
|
257
293
|
include Sidekiq::Worker
|
|
294
|
+
sidekiq_options retry: false
|
|
258
295
|
|
|
259
296
|
def perform
|
|
260
|
-
client = DhanHQ::WS::
|
|
261
|
-
|
|
262
|
-
client.on(:order_update) do |payload|
|
|
263
|
-
OrderStatusUpdater.call(payload)
|
|
297
|
+
client = DhanHQ::WS::Orders.connect do |order_update|
|
|
298
|
+
OrderStatusUpdater.call(order_update)
|
|
264
299
|
end
|
|
265
300
|
|
|
266
301
|
client.on(:error) { |err| Rails.logger.error("Dhan order WS error: #{err}") }
|
|
267
302
|
|
|
268
|
-
|
|
269
|
-
|
|
303
|
+
loop do
|
|
304
|
+
sleep 30
|
|
305
|
+
break unless client.connected?
|
|
306
|
+
end
|
|
270
307
|
end
|
|
271
308
|
end
|
|
272
309
|
```
|
|
273
310
|
|
|
311
|
+
`DhanHQ::WS::Orders::Client` also tracks state for you without any extra
|
|
312
|
+
wiring — `client.order_state(order_no)`, `client.orders_by_status(status)`,
|
|
313
|
+
`client.pending_orders`, and friends stay in sync as updates arrive, so
|
|
314
|
+
`OrderStatusUpdater` doesn't need to re-derive status transitions from raw
|
|
315
|
+
payloads.
|
|
316
|
+
|
|
274
317
|
Inside `OrderStatusUpdater` you can reconcile the payload with your local order
|
|
275
318
|
records, notify users via ActionCable or email, etc.
|
|
276
319
|
|
data/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -50,16 +50,30 @@ client = DhanHQ::WS.connect(mode: :ticker) { |tick| puts tick[:ltp] }
|
|
|
50
50
|
|
|
51
51
|
**Solution:**
|
|
52
52
|
- The client safely drops malformed frames and keeps the event loop alive.
|
|
53
|
-
-
|
|
53
|
+
- Turn on [WebSocket Frame Debugging](#websocket-frame-debugging) below to see the actual bytes that failed to parse.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## WebSocket Frame Debugging
|
|
58
|
+
|
|
59
|
+
**Symptom:** Ticks stopped arriving, or you're seeing "unknown feed kind" / parse errors and need to see what the server actually sent — a bug report of "got garbage data" is unactionable without the raw bytes.
|
|
60
|
+
|
|
61
|
+
**Solution:** Set `DHAN_WS_DEBUG=true` (or `config.ws_debug = true`) alongside debug-level logging. Every raw inbound frame — market feed, order updates, market depth — is logged as a hex dump before it's parsed:
|
|
54
62
|
|
|
55
63
|
```bash
|
|
64
|
+
export DHAN_WS_DEBUG=true
|
|
56
65
|
export DHAN_LOG_LEVEL=DEBUG
|
|
57
66
|
```
|
|
58
67
|
|
|
59
68
|
```ruby
|
|
69
|
+
DhanHQ.configure do |c|
|
|
70
|
+
c.ws_debug = true
|
|
71
|
+
end
|
|
60
72
|
DhanHQ.logger.level = Logger::DEBUG
|
|
61
73
|
```
|
|
62
74
|
|
|
75
|
+
This is off by default and high-volume when on — the flag is checked before any hex encoding work, so leaving it off costs nothing, but a live feed with `DHAN_WS_DEBUG=true` will log every single tick. Turn it off once you have what you need. See [Configuration Reference](CONFIGURATION.md#behavior-flags) for the full flag reference.
|
|
76
|
+
|
|
63
77
|
---
|
|
64
78
|
|
|
65
79
|
## Authentication Errors
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Backtest
|
|
5
|
+
# Aggregated outcome of a Runner run: the trade log, a per-bar equity
|
|
6
|
+
# curve, and derived summary statistics. Pure math over data Runner
|
|
7
|
+
# already computed — no I/O.
|
|
8
|
+
class Result
|
|
9
|
+
attr_reader :trades, :equity_curve, :initial_capital
|
|
10
|
+
|
|
11
|
+
# @param trades [Array<DhanHQ::Backtest::Trade>]
|
|
12
|
+
# @param equity_curve [Array<Float>] mark-to-market equity, one point per candle
|
|
13
|
+
# @param initial_capital [Float]
|
|
14
|
+
def initialize(trades:, equity_curve:, initial_capital:)
|
|
15
|
+
@trades = trades
|
|
16
|
+
@equity_curve = equity_curve
|
|
17
|
+
@initial_capital = initial_capital.to_f
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Float] equity after the last candle (or initial_capital if no candles ran)
|
|
21
|
+
def final_equity
|
|
22
|
+
equity_curve.last || initial_capital
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Float] total return over the run, as a percentage of initial capital
|
|
26
|
+
def total_return_pct
|
|
27
|
+
return 0.0 if initial_capital.zero?
|
|
28
|
+
|
|
29
|
+
((final_equity - initial_capital) / initial_capital) * 100
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Integer] number of completed round-trip trades
|
|
33
|
+
def num_trades
|
|
34
|
+
trades.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<DhanHQ::Backtest::Trade>] trades that closed profitably
|
|
38
|
+
def winning_trades
|
|
39
|
+
trades.select(&:win?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Float] percentage of trades that closed profitably
|
|
43
|
+
def win_rate
|
|
44
|
+
return 0.0 if trades.empty?
|
|
45
|
+
|
|
46
|
+
(winning_trades.size.to_f / trades.size) * 100
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Float] mean pnl across all trades
|
|
50
|
+
def avg_trade_pnl
|
|
51
|
+
return 0.0 if trades.empty?
|
|
52
|
+
|
|
53
|
+
trades.sum(&:pnl) / trades.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Float] largest peak-to-trough decline in the equity curve, as a percentage
|
|
57
|
+
def max_drawdown_pct
|
|
58
|
+
return 0.0 if equity_curve.empty?
|
|
59
|
+
|
|
60
|
+
peak = equity_curve.first
|
|
61
|
+
max_dd = 0.0
|
|
62
|
+
|
|
63
|
+
equity_curve.each do |equity|
|
|
64
|
+
peak = equity if equity > peak
|
|
65
|
+
next if peak.zero?
|
|
66
|
+
|
|
67
|
+
drawdown = ((peak - equity) / peak) * 100
|
|
68
|
+
max_dd = drawdown if drawdown > max_dd
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
max_dd
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Hash] rounded snapshot of the metrics above, for reporting
|
|
75
|
+
def summary
|
|
76
|
+
{
|
|
77
|
+
total_return_pct: total_return_pct.round(2),
|
|
78
|
+
num_trades: num_trades,
|
|
79
|
+
win_rate: win_rate.round(2),
|
|
80
|
+
avg_trade_pnl: avg_trade_pnl.round(2),
|
|
81
|
+
max_drawdown_pct: max_drawdown_pct.round(2),
|
|
82
|
+
final_equity: final_equity.round(2)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Backtest
|
|
5
|
+
# Replays a DhanHQ::Strategy::Base against historical OHLC candles and
|
|
6
|
+
# returns a Result.
|
|
7
|
+
#
|
|
8
|
+
# Both entries and exits fill at the *next* candle's open, never the
|
|
9
|
+
# signal candle's own price — evaluating a signal on a candle and then
|
|
10
|
+
# filling somewhere inside that same candle is look-ahead bias, since the
|
|
11
|
+
# fill price would have to come from before the candle closed (and so
|
|
12
|
+
# before the signal could have fired live). The one exception is the end
|
|
13
|
+
# of the dataset: an open position with no next candle to fill against is
|
|
14
|
+
# force-closed at the last candle's close.
|
|
15
|
+
#
|
|
16
|
+
# Only one position is held at a time, matching DhanHQ::Strategy::Base's
|
|
17
|
+
# single `@position` / entry-then-exit model — no pyramiding, no shorting.
|
|
18
|
+
class Runner
|
|
19
|
+
DEFAULT_QUANTITY = ->(equity, price) { price.positive? ? (equity / price).floor : 0 }
|
|
20
|
+
NO_FEES = ->(_trade_value) { 0.0 }
|
|
21
|
+
|
|
22
|
+
# @param strategy [DhanHQ::Strategy::Base]
|
|
23
|
+
# @param data [DhanHQ::MarketData::OHLCSeries]
|
|
24
|
+
# @param initial_capital [Float]
|
|
25
|
+
# @param quantity [#call, Integer] `->(equity, price) { ... }`, or a fixed integer
|
|
26
|
+
# @param fees [#call] `->(trade_value) { ... }`, charged once per leg (entry, exit)
|
|
27
|
+
# @param max_bars_held [Integer, nil] force-exit a position held this many bars or longer
|
|
28
|
+
def initialize(strategy:, data:, initial_capital:, quantity: DEFAULT_QUANTITY, fees: NO_FEES, max_bars_held: nil)
|
|
29
|
+
@strategy = strategy
|
|
30
|
+
@candles = data.respond_to?(:candles) ? data.candles : Array(data)
|
|
31
|
+
@initial_capital = initial_capital.to_f
|
|
32
|
+
@quantity = quantity.respond_to?(:call) ? quantity : ->(_equity, _price) { quantity }
|
|
33
|
+
@fees = fees
|
|
34
|
+
@max_bars_held = max_bars_held
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [DhanHQ::Backtest::Result]
|
|
38
|
+
def run
|
|
39
|
+
return Result.new(trades: [], equity_curve: [], initial_capital: @initial_capital) if @candles.empty?
|
|
40
|
+
|
|
41
|
+
equity = @initial_capital
|
|
42
|
+
equity_curve = []
|
|
43
|
+
trades = []
|
|
44
|
+
window_candles = []
|
|
45
|
+
open_trade = nil
|
|
46
|
+
entry_index = nil
|
|
47
|
+
|
|
48
|
+
@candles.each_with_index do |candle, index|
|
|
49
|
+
window_candles << candle
|
|
50
|
+
window = DhanHQ::MarketData::OHLCSeries.new(window_candles)
|
|
51
|
+
has_next = index < @candles.size - 1
|
|
52
|
+
|
|
53
|
+
if open_trade && has_next
|
|
54
|
+
trade = maybe_exit(open_trade, window, index, entry_index, equity, equity_curve, @candles[index + 1])
|
|
55
|
+
|
|
56
|
+
if trade
|
|
57
|
+
trades << trade
|
|
58
|
+
equity += trade.pnl
|
|
59
|
+
open_trade = nil
|
|
60
|
+
entry_index = nil
|
|
61
|
+
end
|
|
62
|
+
elsif open_trade.nil? && has_next
|
|
63
|
+
open_trade, entry_index = maybe_enter(window, @candles[index + 1], equity, index)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A position opened this very iteration (via maybe_enter) isn't filled until the
|
|
67
|
+
# *next* candle — mark-to-market must stay flat until index reaches entry_index,
|
|
68
|
+
# or the equity curve would price the position off a fill that hasn't happened yet.
|
|
69
|
+
filled = open_trade && index >= entry_index
|
|
70
|
+
equity_curve << mark_to_market(equity, filled ? open_trade : nil, candle.close)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if open_trade
|
|
74
|
+
last = @candles.last
|
|
75
|
+
trade = close_trade(open_trade, last.timestamp, last.close, :end_of_data)
|
|
76
|
+
trades << trade
|
|
77
|
+
equity += trade.pnl
|
|
78
|
+
equity_curve[-1] = equity
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Result.new(trades: trades, equity_curve: equity_curve, initial_capital: @initial_capital)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def maybe_enter(window, next_candle, equity, index)
|
|
87
|
+
signal = @strategy.evaluate_entry(window)
|
|
88
|
+
return [nil, nil] unless signal.buy?
|
|
89
|
+
|
|
90
|
+
qty = @quantity.call(equity, next_candle.open)
|
|
91
|
+
return [nil, nil] unless qty.positive?
|
|
92
|
+
|
|
93
|
+
[{ entry_time: next_candle.timestamp, entry_price: next_candle.open, quantity: qty }, index + 1]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def maybe_exit(open_trade, window, index, entry_index, equity, equity_curve, next_candle)
|
|
97
|
+
bars_held = index - entry_index
|
|
98
|
+
forced = @max_bars_held && bars_held >= @max_bars_held
|
|
99
|
+
drawdown = drawdown_pct(equity_curve, equity)
|
|
100
|
+
violations = @strategy.check_risks(equity: equity, position: open_trade, drawdown: drawdown)
|
|
101
|
+
signal = @strategy.evaluate_exit(window)
|
|
102
|
+
|
|
103
|
+
return unless forced || violations.any? || signal.sell?
|
|
104
|
+
|
|
105
|
+
reason = if forced
|
|
106
|
+
:max_bars_held
|
|
107
|
+
elsif violations.any?
|
|
108
|
+
:risk_violation
|
|
109
|
+
else
|
|
110
|
+
:signal
|
|
111
|
+
end
|
|
112
|
+
close_trade(open_trade, next_candle.timestamp, next_candle.open, reason)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def close_trade(open_trade, exit_time, exit_price, reason)
|
|
116
|
+
entry_value = open_trade[:entry_price] * open_trade[:quantity]
|
|
117
|
+
exit_value = exit_price * open_trade[:quantity]
|
|
118
|
+
|
|
119
|
+
Trade.new(
|
|
120
|
+
entry_time: open_trade[:entry_time],
|
|
121
|
+
entry_price: open_trade[:entry_price],
|
|
122
|
+
exit_time: exit_time,
|
|
123
|
+
exit_price: exit_price,
|
|
124
|
+
quantity: open_trade[:quantity],
|
|
125
|
+
exit_reason: reason,
|
|
126
|
+
fees: @fees.call(entry_value) + @fees.call(exit_value)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def mark_to_market(equity, open_trade, close_price)
|
|
131
|
+
return equity unless open_trade
|
|
132
|
+
|
|
133
|
+
equity + ((close_price - open_trade[:entry_price]) * open_trade[:quantity])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def drawdown_pct(equity_curve, current_equity)
|
|
137
|
+
peak = (equity_curve + [current_equity]).max
|
|
138
|
+
return 0.0 if peak.nil? || peak.zero?
|
|
139
|
+
|
|
140
|
+
[((peak - current_equity) / peak) * 100, 0.0].max
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Backtest
|
|
5
|
+
# A single completed long round-trip trade produced by Runner.
|
|
6
|
+
#
|
|
7
|
+
# `fees` is the total cost charged across both legs (entry + exit), already
|
|
8
|
+
# netted into `pnl` — it is not deducted again by callers.
|
|
9
|
+
Trade = Struct.new(
|
|
10
|
+
:entry_time, :entry_price, :exit_time, :exit_price, :quantity, :exit_reason, :fees
|
|
11
|
+
) do
|
|
12
|
+
# Net profit/loss for this trade, after fees.
|
|
13
|
+
#
|
|
14
|
+
# @return [Float]
|
|
15
|
+
def pnl
|
|
16
|
+
((exit_price - entry_price) * quantity) - fees.to_f
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Net profit/loss as a percentage of the entry value.
|
|
20
|
+
#
|
|
21
|
+
# @return [Float]
|
|
22
|
+
def pnl_pct
|
|
23
|
+
entry_value = entry_price.to_f * quantity.to_f
|
|
24
|
+
return 0.0 if entry_value.zero?
|
|
25
|
+
|
|
26
|
+
(pnl / entry_value) * 100
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] whether this trade closed profitably after fees
|
|
30
|
+
def win?
|
|
31
|
+
pnl.positive?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Backtesting engine that replays a DhanHQ::Strategy::Base against historical
|
|
5
|
+
# OHLC data and reports trades, an equity curve, and summary performance stats.
|
|
6
|
+
#
|
|
7
|
+
# @example Backtest a strategy against daily candles
|
|
8
|
+
# data = DhanHQ::Models::HistoricalData.daily(
|
|
9
|
+
# security_id: "1333", exchange_segment: "NSE_EQ",
|
|
10
|
+
# instrument: "EQUITY", from_date: "2024-01-01", to_date: "2024-12-31"
|
|
11
|
+
# )
|
|
12
|
+
# series = DhanHQ::MarketData::OHLCSeries.from_response(data)
|
|
13
|
+
#
|
|
14
|
+
# result = DhanHQ::Backtest::Runner.new(
|
|
15
|
+
# strategy: MyStrategy.new,
|
|
16
|
+
# data: series,
|
|
17
|
+
# initial_capital: 100_000.0
|
|
18
|
+
# ).run
|
|
19
|
+
#
|
|
20
|
+
# result.summary #=> { total_return_pct: 12.4, num_trades: 8, win_rate: 62.5, ... }
|
|
21
|
+
#
|
|
22
|
+
module Backtest
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -132,6 +132,15 @@ module DhanHQ
|
|
|
132
132
|
# @return [Integer]
|
|
133
133
|
attr_accessor :market_depth_level
|
|
134
134
|
|
|
135
|
+
# When true, every raw inbound WebSocket frame (market feed, order updates,
|
|
136
|
+
# market depth) is logged as a hex dump at debug level before it's parsed.
|
|
137
|
+
# Off by default -- high volume, and the check happens before any hex
|
|
138
|
+
# encoding work so there's no cost when disabled.
|
|
139
|
+
#
|
|
140
|
+
# Set via +DHAN_WS_DEBUG=true+ or in {DhanHQ.configure}.
|
|
141
|
+
# @return [Boolean]
|
|
142
|
+
attr_accessor :ws_debug
|
|
143
|
+
|
|
135
144
|
# Setters for websocket URLs
|
|
136
145
|
attr_writer :ws_order_url, :ws_market_feed_url, :ws_market_depth_url
|
|
137
146
|
|
|
@@ -198,6 +207,11 @@ module DhanHQ
|
|
|
198
207
|
@auto_correlation_id == true
|
|
199
208
|
end
|
|
200
209
|
|
|
210
|
+
# @return [Boolean] True when raw WebSocket frames should be hex-logged.
|
|
211
|
+
def ws_debug?
|
|
212
|
+
@ws_debug == true
|
|
213
|
+
end
|
|
214
|
+
|
|
201
215
|
# Initializes a new configuration instance with default values.
|
|
202
216
|
#
|
|
203
217
|
# @example
|
|
@@ -218,6 +232,7 @@ module DhanHQ
|
|
|
218
232
|
@ws_market_feed_url = ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
|
|
219
233
|
@ws_market_depth_url = ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
|
|
220
234
|
@market_depth_level = ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20").to_i
|
|
235
|
+
@ws_debug = env_flag("DHAN_WS_DEBUG", default: false)
|
|
221
236
|
@ws_user_type = ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
|
|
222
237
|
@partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
|
|
223
238
|
@partner_secret = ENV.fetch("DHAN_PARTNER_SECRET", nil)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Jobs
|
|
5
|
+
# Places a Dhan order via ActiveJob, without ever letting a queue adapter
|
|
6
|
+
# (Sidekiq, Resque, ...) retry it.
|
|
7
|
+
#
|
|
8
|
+
# DhanHQ order writes are not idempotent -- a timed-out POST /v2/orders may
|
|
9
|
+
# already have reached the exchange, so retrying it can place a duplicate
|
|
10
|
+
# order (see the README's "Order Retries and Duplicate Protection"). Most
|
|
11
|
+
# adapters retry unhandled exceptions at the backend level by default
|
|
12
|
+
# (Sidekiq's own retry, independent of ActiveJob's opt-in `retry_on`), so
|
|
13
|
+
# the only adapter-agnostic way to guarantee this write is never silently
|
|
14
|
+
# retried is to make sure the exception never reaches the adapter at all.
|
|
15
|
+
# `discard_on` does exactly that: it's handled inside ActiveJob's own
|
|
16
|
+
# `execute`, so from the adapter's point of view the job completed, not
|
|
17
|
+
# failed -- there is nothing left for the adapter's own retry logic to
|
|
18
|
+
# act on.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# DhanHQ::Jobs::PlaceOrderJob.perform_later(
|
|
22
|
+
# transaction_type: DhanHQ::Constants::TransactionType::BUY,
|
|
23
|
+
# exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
|
|
24
|
+
# product_type: DhanHQ::Constants::ProductType::INTRADAY,
|
|
25
|
+
# order_type: DhanHQ::Constants::OrderType::LIMIT,
|
|
26
|
+
# validity: DhanHQ::Constants::Validity::DAY,
|
|
27
|
+
# security_id: "11536",
|
|
28
|
+
# quantity: 5,
|
|
29
|
+
# price: 1500.0
|
|
30
|
+
# )
|
|
31
|
+
class PlaceOrderJob < ActiveJob::Base
|
|
32
|
+
discard_on DhanHQ::OrderError do |_job, error|
|
|
33
|
+
DhanHQ.logger&.error("[DhanHQ::Jobs::PlaceOrderJob] order rejected: #{error.message}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
discard_on DhanHQ::RiskViolation do |_job, error|
|
|
37
|
+
DhanHQ.logger&.warn("[DhanHQ::Jobs::PlaceOrderJob] risk check blocked the order: #{error.message}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param params [Hash] Same params accepted by DhanHQ::Models::Order.place.
|
|
41
|
+
# @return [DhanHQ::Models::Order]
|
|
42
|
+
def perform(params)
|
|
43
|
+
DhanHQ::Models::Order.place!(params)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/DhanHQ/version.rb
CHANGED
|
@@ -212,6 +212,7 @@ module DhanHQ
|
|
|
212
212
|
# Handle WebSocket message event
|
|
213
213
|
# @param ev [Event] WebSocket message event
|
|
214
214
|
def handle_message(ev)
|
|
215
|
+
WS.debug_frame(self.class.name, ev.data)
|
|
215
216
|
emit(:raw, ev.data)
|
|
216
217
|
process_message(ev.data) if respond_to?(:process_message, true)
|
|
217
218
|
end
|
data/lib/DhanHQ/ws/connection.rb
CHANGED
|
@@ -63,6 +63,7 @@ module DhanHQ
|
|
|
63
63
|
# Process incoming WebSocket message
|
|
64
64
|
# @param ev [Event] WebSocket message event
|
|
65
65
|
def handle_message(ev)
|
|
66
|
+
WS.debug_frame(self.class.name, ev.data)
|
|
66
67
|
msg = JSON.parse(ev.data, symbolize_names: true)
|
|
67
68
|
emit(:raw, msg)
|
|
68
69
|
emit(:message, msg)
|
data/lib/DhanHQ/ws.rb
CHANGED
|
@@ -34,5 +34,30 @@ module DhanHQ
|
|
|
34
34
|
def self.disconnect_all_local!
|
|
35
35
|
Registry.stop_all
|
|
36
36
|
end
|
|
37
|
+
|
|
38
|
+
# Cap on how many bytes of a frame get hex-dumped by {debug_frame}. A full
|
|
39
|
+
# depth packet can run several KB; at tick frequency that floods the log
|
|
40
|
+
# long before it adds diagnostic value beyond the first couple hundred
|
|
41
|
+
# bytes (header + the first few fields is normally enough to spot a
|
|
42
|
+
# parsing bug). The full frame size is always logged regardless of the cap.
|
|
43
|
+
DEBUG_FRAME_MAX_BYTES = 256
|
|
44
|
+
|
|
45
|
+
# Logs a raw inbound WebSocket frame as a hex dump when
|
|
46
|
+
# +config.ws_debug+ (+DHAN_WS_DEBUG=true+) is enabled. A no-op otherwise --
|
|
47
|
+
# the flag is checked before any hex encoding work, so there's no cost
|
|
48
|
+
# when debug logging is off. Frames longer than {DEBUG_FRAME_MAX_BYTES}
|
|
49
|
+
# are truncated in the dump, but the logged byte count is always the full
|
|
50
|
+
# frame size.
|
|
51
|
+
#
|
|
52
|
+
# @param source [String] short tag identifying which connection the frame came from
|
|
53
|
+
# @param data [String] raw frame bytes
|
|
54
|
+
# @return [void]
|
|
55
|
+
def self.debug_frame(source, data)
|
|
56
|
+
return unless DhanHQ.configuration&.ws_debug?
|
|
57
|
+
|
|
58
|
+
hex = data.byteslice(0, DEBUG_FRAME_MAX_BYTES).unpack1("H*")
|
|
59
|
+
hex += "...truncated" if data.bytesize > DEBUG_FRAME_MAX_BYTES
|
|
60
|
+
DhanHQ.logger&.debug("[DhanHQ::WS::#{source}] frame (#{data.bytesize} bytes): #{hex}")
|
|
61
|
+
end
|
|
37
62
|
end
|
|
38
63
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Dhanhq
|
|
6
|
+
# Scaffolds a DhanHQ initializer, a sample order-placing service object, and
|
|
7
|
+
# a Sidekiq worker + ActionCable channel for streaming market data.
|
|
8
|
+
#
|
|
9
|
+
# rails generate dhanhq:install
|
|
10
|
+
#
|
|
11
|
+
class InstallGenerator < Rails::Generators::Base
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def create_initializer
|
|
15
|
+
template "initializer.rb", "config/initializers/dhanhq.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_order_service
|
|
19
|
+
template "place_order_service.rb", "app/services/dhan/orders/place_order.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_market_feed_worker
|
|
23
|
+
template "market_feed_worker.rb", "app/workers/dhan_market_feed_worker.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_market_feed_channel
|
|
27
|
+
template "market_feed_channel.rb", "app/channels/dhan_market_feed_channel.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show_post_install_message
|
|
31
|
+
say ""
|
|
32
|
+
say "DhanHQ installed! Next steps:", :green
|
|
33
|
+
say " 1. Add your credentials:"
|
|
34
|
+
say " rails credentials:edit"
|
|
35
|
+
say " dhanhq:"
|
|
36
|
+
say " client_id: \"your_client_id\""
|
|
37
|
+
say " access_token: \"your_access_token\""
|
|
38
|
+
say " 2. Set LIVE_TRADING=true before placing real orders (see docs/CONFIGURATION.md)"
|
|
39
|
+
say " 3. Start the market feed: DhanMarketFeedWorker.perform_async"
|
|
40
|
+
say ""
|
|
41
|
+
say "Full reference: https://github.com/shubhamtaywade82/dhanhq-client/blob/main/docs/RAILS_INTEGRATION.md"
|
|
42
|
+
say ""
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dhan_hq"
|
|
4
|
+
|
|
5
|
+
if (creds = Rails.application.credentials.dig(:dhanhq))
|
|
6
|
+
ENV["DHAN_CLIENT_ID"] ||= creds[:client_id]
|
|
7
|
+
ENV["DHAN_ACCESS_TOKEN"] ||= creds[:access_token]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
DhanHQ.configure_with_env
|
|
11
|
+
|
|
12
|
+
log_level = (ENV["DHAN_LOG_LEVEL"] || "INFO").upcase
|
|
13
|
+
DhanHQ.logger.level = Logger.const_get(log_level)
|
|
14
|
+
|
|
15
|
+
# Full optional configuration (base_url, ws_order_url, partner auth, timeouts,
|
|
16
|
+
# DHAN_WS_DEBUG, ...) is documented in:
|
|
17
|
+
# https://github.com/shubhamtaywade82/dhanhq-client/blob/main/docs/CONFIGURATION.md
|
|
18
|
+
# https://github.com/shubhamtaywade82/dhanhq-client/blob/main/docs/RAILS_INTEGRATION.md
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Keeps a DhanHQ market feed connection open and broadcasts ticks over
|
|
4
|
+
# ActionCable. Start it with DhanMarketFeedWorker.perform_async -- the job
|
|
5
|
+
# blocks for the life of the connection rather than completing immediately,
|
|
6
|
+
# so Sidekiq's dashboard reflects whether the feed is actually running.
|
|
7
|
+
#
|
|
8
|
+
# retry: false because there is nothing to retry: the underlying
|
|
9
|
+
# DhanHQ::WS::Client already reconnects and re-subscribes on its own.
|
|
10
|
+
class DhanMarketFeedWorker
|
|
11
|
+
include Sidekiq::Worker
|
|
12
|
+
sidekiq_options retry: false
|
|
13
|
+
|
|
14
|
+
def perform(mode = "quote")
|
|
15
|
+
client = DhanHQ::WS.connect(mode: mode.to_sym) do |tick|
|
|
16
|
+
ActionCable.server.broadcast("dhan_market_feed", tick)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
client.on(:reconnect) { |info| Rails.logger.warn("[DhanMarketFeedWorker] reconnect ##{info[:attempt]}") }
|
|
20
|
+
client.on(:error) { |message| Rails.logger.error("[DhanMarketFeedWorker] #{message}") }
|
|
21
|
+
|
|
22
|
+
loop do
|
|
23
|
+
sleep 30
|
|
24
|
+
break unless client.connected?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dhan
|
|
4
|
+
module Orders
|
|
5
|
+
# Places a Dhan order via the bang variant, so a rejection raises a
|
|
6
|
+
# specific, catchable error instead of Order.place's ambiguous
|
|
7
|
+
# nil/false/ErrorObject return.
|
|
8
|
+
class PlaceOrder
|
|
9
|
+
def initialize(params)
|
|
10
|
+
@params = params
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
DhanHQ::Models::Order.place!(@params)
|
|
15
|
+
rescue DhanHQ::OrderError => e
|
|
16
|
+
Rails.logger.error("Dhan order rejected: #{e.message}")
|
|
17
|
+
raise
|
|
18
|
+
rescue DhanHQ::RiskViolation => e
|
|
19
|
+
Rails.logger.warn("Dhan risk check blocked the order: #{e.message}")
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: DhanHQ
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shubham Taywade
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -84,16 +84,16 @@ dependencies:
|
|
|
84
84
|
name: dry-validation
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
86
86
|
requirements:
|
|
87
|
-
- - "
|
|
87
|
+
- - "~>"
|
|
88
88
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: '
|
|
89
|
+
version: '1.11'
|
|
90
90
|
type: :runtime
|
|
91
91
|
prerelease: false
|
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
93
|
requirements:
|
|
94
|
-
- - "
|
|
94
|
+
- - "~>"
|
|
95
95
|
- !ruby/object:Gem::Version
|
|
96
|
-
version: '
|
|
96
|
+
version: '1.11'
|
|
97
97
|
- !ruby/object:Gem::Dependency
|
|
98
98
|
name: eventmachine
|
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -164,10 +164,11 @@ dependencies:
|
|
|
164
164
|
- - ">="
|
|
165
165
|
- !ruby/object:Gem::Version
|
|
166
166
|
version: '0'
|
|
167
|
-
description: A production-grade Ruby SDK for Dhan API v2 built for
|
|
168
|
-
monitoring, and live trading systems
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
description: A production-grade Ruby SDK and Ruby client for Dhan API v2 built for
|
|
168
|
+
algorithmic trading, portfolio monitoring, and live trading systems on NSE, BSE
|
|
169
|
+
and MCX. Provides typed models, token lifecycle management, dry-validation contracts,
|
|
170
|
+
resilient WebSocket streaming with auto-reconnect, and safety-focused order workflows
|
|
171
|
+
for Ruby on Rails and standalone Ruby applications.
|
|
171
172
|
email:
|
|
172
173
|
- shubhamtaywade82@gmail.com
|
|
173
174
|
executables:
|
|
@@ -228,6 +229,10 @@ files:
|
|
|
228
229
|
- lib/DhanHQ/auth/token_generator.rb
|
|
229
230
|
- lib/DhanHQ/auth/token_manager.rb
|
|
230
231
|
- lib/DhanHQ/auth/token_renewal.rb
|
|
232
|
+
- lib/DhanHQ/backtest.rb
|
|
233
|
+
- lib/DhanHQ/backtest/result.rb
|
|
234
|
+
- lib/DhanHQ/backtest/runner.rb
|
|
235
|
+
- lib/DhanHQ/backtest/trade.rb
|
|
231
236
|
- lib/DhanHQ/client.rb
|
|
232
237
|
- lib/DhanHQ/concerns/bang_writes.rb
|
|
233
238
|
- lib/DhanHQ/concerns/order_audit.rb
|
|
@@ -283,6 +288,7 @@ files:
|
|
|
283
288
|
- lib/DhanHQ/helpers/response_helper.rb
|
|
284
289
|
- lib/DhanHQ/helpers/validation_helper.rb
|
|
285
290
|
- lib/DhanHQ/indicators.rb
|
|
291
|
+
- lib/DhanHQ/jobs/place_order_job.rb
|
|
286
292
|
- lib/DhanHQ/json_loader.rb
|
|
287
293
|
- lib/DhanHQ/market_data.rb
|
|
288
294
|
- lib/DhanHQ/market_data/market_snapshot.rb
|
|
@@ -433,6 +439,11 @@ files:
|
|
|
433
439
|
- lib/dhanhq/analysis/multi_timeframe_analyzer.rb
|
|
434
440
|
- lib/dhanhq/analysis/options_buying_advisor.rb
|
|
435
441
|
- lib/dhanhq/contracts/options_buying_advisor_contract.rb
|
|
442
|
+
- lib/generators/dhanhq/install/install_generator.rb
|
|
443
|
+
- lib/generators/dhanhq/install/templates/initializer.rb
|
|
444
|
+
- lib/generators/dhanhq/install/templates/market_feed_channel.rb
|
|
445
|
+
- lib/generators/dhanhq/install/templates/market_feed_worker.rb
|
|
446
|
+
- lib/generators/dhanhq/install/templates/place_order_service.rb
|
|
436
447
|
- lib/rubocop/cop/dhanhq/use_constants.rb
|
|
437
448
|
- lib/ta.rb
|
|
438
449
|
- lib/ta/candles.rb
|
|
@@ -469,14 +480,15 @@ files:
|
|
|
469
480
|
- skills/dhanhq-ruby/scripts/resolve_security.rb
|
|
470
481
|
- skills/dhanhq-ruby/scripts/trade_logger.rb
|
|
471
482
|
- skills/dhanhq-ruby/scripts/validate_order.rb
|
|
472
|
-
homepage: https://github.
|
|
483
|
+
homepage: https://shubhamtaywade82.github.io/dhanhq-client/
|
|
473
484
|
licenses:
|
|
474
485
|
- MIT
|
|
475
486
|
metadata:
|
|
476
487
|
allowed_push_host: https://rubygems.org
|
|
477
|
-
homepage_uri: https://github.
|
|
488
|
+
homepage_uri: https://shubhamtaywade82.github.io/dhanhq-client/
|
|
478
489
|
source_code_uri: https://github.com/shubhamtaywade82/dhanhq-client
|
|
479
490
|
changelog_uri: https://github.com/shubhamtaywade82/dhanhq-client/blob/main/CHANGELOG.md
|
|
491
|
+
documentation_uri: https://shubhamtaywade82.github.io/dhanhq-client/
|
|
480
492
|
rubygems_mfa_required: 'true'
|
|
481
493
|
post_install_message:
|
|
482
494
|
rdoc_options: []
|
|
@@ -496,5 +508,6 @@ requirements: []
|
|
|
496
508
|
rubygems_version: 3.5.11
|
|
497
509
|
signing_key:
|
|
498
510
|
specification_version: 4
|
|
499
|
-
summary:
|
|
511
|
+
summary: Production-grade Ruby SDK for Dhan API v2 with REST APIs, WebSocket market
|
|
512
|
+
data, token lifecycle management, dry-validation contracts and trading workflows.
|
|
500
513
|
test_files: []
|