DhanHQ 4.1.0 → 4.1.1

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.
@@ -13,12 +13,17 @@ module DhanHQ
13
13
  # WebSocket client for Full Market Depth data
14
14
  # Provides real-time market depth (bid/ask levels) for specified symbols
15
15
  class Client < BaseConnection
16
- # Feed request codes per the official Annexure "Feed Request Code" table
17
- # (dhanhq.co/docs/v2/annexure#feed-request-code): 23 = Subscribe - Full
18
- # Market Depth, 24 = Unsubscribe - Full Market Depth. Never 12 - that is
19
- # the whole-connection disconnect, not a per-instrument unsubscribe.
16
+ # Feed request codes per the Annexure "Feed Request Code" table and the
17
+ # Full Market Depth "Feed Disconnect" section (docs.dhanhq.co):
18
+ # 23 = Subscribe - Full Market Depth, 25 = Unsubscribe - Full Market Depth.
19
+ # Never 12 - that is the whole-connection disconnect, not a
20
+ # per-instrument unsubscribe.
20
21
  SUBSCRIBE_REQUEST_CODE = 23
21
- UNSUBSCRIBE_REQUEST_CODE = 24
22
+ UNSUBSCRIBE_REQUEST_CODE = 25
23
+
24
+ # Instruments one connection may carry: 50 for 20-level depth, exactly
25
+ # one for 200-level depth.
26
+ MAX_INSTRUMENTS = { 20 => 50, 200 => 1 }.freeze
22
27
 
23
28
  ##
24
29
  # Initialize Market Depth WebSocket client
@@ -30,10 +35,12 @@ module DhanHQ
30
35
  url = options[:url] || build_market_depth_url(cfg)
31
36
  super(url: url, config: cfg, **options)
32
37
 
33
- @symbols = Array(symbols)
38
+ @symbols = symbol_list(symbols)
34
39
  @subscriptions = Concurrent::Map.new
35
40
  @instrument_cache = Concurrent::Map.new
36
- @decoder = Decoder.new
41
+ @depth_level = cfg.market_depth_level.to_i == 200 ? 200 : 20
42
+ @decoder = Decoder.new(depth_level: @depth_level)
43
+ @books = {}
37
44
  end
38
45
 
39
46
  ##
@@ -50,7 +57,7 @@ module DhanHQ
50
57
  # @param symbols [Array<String, Hash>] Symbols (or metadata hashes) to subscribe to
51
58
  # @return [Client] self for method chaining
52
59
  def subscribe(symbols)
53
- subscribe_to_symbols(Array(symbols))
60
+ subscribe_to_symbols(symbol_list(symbols))
54
61
  self
55
62
  end
56
63
 
@@ -59,7 +66,7 @@ module DhanHQ
59
66
  # @param symbols [Array<String, Hash>] Symbols (or metadata hashes) to unsubscribe from
60
67
  # @return [Client] self for method chaining
61
68
  def unsubscribe(symbols)
62
- unsubscribe_from_symbols(Array(symbols))
69
+ unsubscribe_from_symbols(symbol_list(symbols))
63
70
  self
64
71
  end
65
72
 
@@ -135,16 +142,77 @@ module DhanHQ
135
142
  # Process incoming WebSocket message
136
143
  # @param data [String] Raw message data
137
144
  def process_message(data)
138
- depth_data = @decoder.decode(data)
139
- return unless depth_data
145
+ touched = []
146
+ @decoder.decode(data).each do |packet|
147
+ next handle_disconnect_packet(packet) if packet[:type] == :disconnect
148
+
149
+ key = [packet[:exchange_segment], packet[:security_id]]
150
+ book = (@books[key] ||= { bids: [], asks: [] })
151
+ book[packet[:type] == :bid ? :bids : :asks] = packet[:levels]
152
+ touched << key unless touched.include?(key)
153
+ end
140
154
 
141
- emit(:depth_update, depth_data)
155
+ touched.each { |key| emit(:depth_update, depth_snapshot(*key)) }
142
156
  emit(:raw_depth, data) # For debugging
143
157
  rescue StandardError => e
144
158
  DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth] Error processing message: #{e.class} #{e.message}")
145
159
  emit(:error, e)
146
160
  end
147
161
 
162
+ # A single instrument hash is one symbol; Array(hash) would split it into pairs.
163
+ def symbol_list(symbols)
164
+ symbols.is_a?(Hash) ? [symbols] : Array(symbols)
165
+ end
166
+
167
+ # Merged order book for one instrument, combining the latest bid and ask
168
+ # packets (they arrive separately on the wire).
169
+ def depth_snapshot(segment, security_id)
170
+ book = @books.fetch([segment, security_id])
171
+ meta = @subscriptions.values.find { |m| m[:security_id] == security_id && m[:exchange_segment] == segment }
172
+ best_bid = book[:bids].first&.fetch(:price)
173
+ best_ask = book[:asks].first&.fetch(:price)
174
+
175
+ {
176
+ type: :depth_update,
177
+ depth_level: @depth_level,
178
+ symbol: meta&.fetch(:symbol, nil),
179
+ exchange_segment: segment,
180
+ security_id: security_id,
181
+ timestamp: Time.now.to_i,
182
+ bids: book[:bids],
183
+ asks: book[:asks],
184
+ best_bid: best_bid,
185
+ best_ask: best_ask,
186
+ spread: best_bid && best_ask ? best_ask - best_bid : nil,
187
+ total_bid_qty: book[:bids].sum { |level| level[:quantity] },
188
+ total_ask_qty: book[:asks].sum { |level| level[:quantity] }
189
+ }
190
+ end
191
+
192
+ def handle_disconnect_packet(packet)
193
+ DhanHQ.logger&.warn("[DhanHQ::WS::MarketDepth] Server disconnect packet, reason code #{packet[:code]}")
194
+ emit(:disconnect, packet)
195
+ end
196
+
197
+ # 20-level takes an instrument list; 200-level takes one flat instrument
198
+ # (docs.dhanhq.co Full Market Depth, "Adding Instruments").
199
+ def subscription_message(resolution)
200
+ instrument = { "ExchangeSegment" => resolution[:exchange_segment], "SecurityId" => resolution[:security_id] }
201
+ return { "RequestCode" => SUBSCRIBE_REQUEST_CODE }.merge(instrument) if @depth_level == 200
202
+
203
+ { "RequestCode" => SUBSCRIBE_REQUEST_CODE, "InstrumentCount" => 1, "InstrumentList" => [instrument] }
204
+ end
205
+
206
+ def capacity_reached?(resolution)
207
+ return false if @subscriptions.size < MAX_INSTRUMENTS.fetch(@depth_level)
208
+
209
+ DhanHQ.logger&.error(
210
+ "[DhanHQ::WS::MarketDepth] Not subscribing #{resolution[:original_label]}: #{@depth_level}-level depth " \
211
+ "allows #{MAX_INSTRUMENTS.fetch(@depth_level)} instrument(s) per connection"
212
+ )
213
+ true
214
+ end
215
+
148
216
  ##
149
217
  # Subscribe to symbols
150
218
  # @param symbols [Array<String, Hash>] Symbols to subscribe to
@@ -155,19 +223,9 @@ module DhanHQ
155
223
 
156
224
  label = resolution[:label]
157
225
  next if @subscriptions.key?(label)
226
+ next if capacity_reached?(resolution)
158
227
 
159
- subscription_message = {
160
- "RequestCode" => SUBSCRIBE_REQUEST_CODE,
161
- "InstrumentCount" => 1,
162
- "InstrumentList" => [
163
- {
164
- "ExchangeSegment" => resolution[:exchange_segment],
165
- "SecurityId" => resolution[:security_id]
166
- }
167
- ]
168
- }
169
-
170
- send_message(subscription_message)
228
+ send_message(subscription_message(resolution))
171
229
  @subscriptions[label] = resolution
172
230
  DhanHQ.logger&.info(
173
231
  "[DhanHQ::WS::MarketDepth] Subscribed to #{resolution[:original_label]} " \
@@ -200,6 +258,7 @@ module DhanHQ
200
258
 
201
259
  send_message(unsubscribe_message)
202
260
  @subscriptions.delete(label)
261
+ @books.delete([security_data[:exchange_segment], security_data[:security_id]])
203
262
  DhanHQ.logger&.info(
204
263
  "[DhanHQ::WS::MarketDepth] Unsubscribed from #{security_data[:original_label]} " \
205
264
  "(#{security_data[:exchange_segment]}:#{security_data[:security_id]})"
@@ -1,129 +1,136 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../segments"
4
+
3
5
  module DhanHQ
4
6
  module WS
5
7
  module MarketDepth
6
8
  ##
7
- # Decoder for Market Depth WebSocket messages
8
- # Handles parsing of market depth data (bid/ask levels)
9
+ # Decoder for the binary 20/200-level Full Market Depth feed.
10
+ #
11
+ # Wire format (docs.dhanhq.co "Full Market Depth", little-endian, no padding):
12
+ #
13
+ # Header (12 bytes)
14
+ # 0-1 int16 message length of the packet
15
+ # 2 byte feed response code: 41 = bid side, 51 = ask side, 50 = disconnect
16
+ # 3 byte exchange segment (Annexure numeric enum)
17
+ # 4-7 int32 security id
18
+ # 8-11 uint32 20-level: message sequence (ignored) / 200-level: number of rows
19
+ # Rows (16 bytes each; 20 for 20-level, "number of rows" for 200-level)
20
+ # 0-7 float64 price
21
+ # 8-11 uint32 quantity
22
+ # 12-15 uint32 number of orders
23
+ #
24
+ # Bid and ask arrive as separate packets, and several packets (per side,
25
+ # per instrument) are stacked in one WebSocket message, so {#decode}
26
+ # walks the frame packet by packet using each header's length.
9
27
  class Decoder
28
+ HEADER_SIZE = 12
29
+ ROW_SIZE = 16
30
+ BID_CODE = 41
31
+ ASK_CODE = 51
32
+ DISCONNECT_CODE = 50
33
+ TWENTY_LEVEL_ROWS = 20
34
+
35
+ # @param depth_level [Integer] 20 or 200; decides how header bytes 8-11 are read.
36
+ def initialize(depth_level: 20)
37
+ @depth_level = depth_level.to_i == 200 ? 200 : 20
38
+ end
39
+
10
40
  ##
11
- # Decode raw WebSocket message to market depth data
12
- # @param data [String] Raw WebSocket message
13
- # @return [Hash, nil] Parsed market depth data or nil if invalid
41
+ # Decode one WebSocket message into its side packets.
42
+ #
43
+ # @param data [String, Array<Integer>] Raw frame (Faye delivers binary frames as byte arrays).
44
+ # @return [Array<Hash>] One entry per packet, in wire order. Side packets are
45
+ # +{ type: :bid | :ask, exchange_segment:, security_id:, levels: [{ price:, quantity:, orders: }] }+;
46
+ # a disconnect packet is +{ type: :disconnect, exchange_segment:, security_id:, code: }+.
47
+ # Malformed trailing bytes are dropped (and logged), never raised.
14
48
  def decode(data)
15
- return nil if data.nil? || data.empty?
16
-
17
- begin
18
- # Parse JSON message
19
- message = JSON.parse(data)
20
-
21
- # Handle different message types
22
- case message["Type"]
23
- when "depth_update"
24
- parse_depth_update(message["Data"])
25
- when "depth_snapshot"
26
- parse_depth_snapshot(message["Data"])
27
- else
28
- # Unknown message type
29
- nil
30
- end
31
- rescue JSON::ParserError => e
32
- DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth::Decoder] JSON parse error: #{e.message}")
33
- nil
34
- rescue StandardError => e
35
- DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth::Decoder] Decode error: #{e.class} #{e.message}")
36
- nil
49
+ bytes = to_binary(data)
50
+ packets = []
51
+ offset = 0
52
+
53
+ while bytes.bytesize - offset >= HEADER_SIZE
54
+ packet, consumed = read_packet(bytes, offset)
55
+ break unless packet
56
+
57
+ packets << packet
58
+ offset += consumed
37
59
  end
60
+
61
+ log_trailing(bytes.bytesize - offset) if offset < bytes.bytesize
62
+ packets
63
+ rescue StandardError => e
64
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth::Decoder] Decode error: #{e.class} #{e.message}")
65
+ []
38
66
  end
39
67
 
40
68
  private
41
69
 
42
- ##
43
- # Parse depth update message
44
- # @param data [Hash] Message data
45
- # @return [Hash] Parsed depth update
46
- def parse_depth_update(data)
47
- {
48
- type: :depth_update,
49
- symbol: data["Symbol"],
50
- exchange_segment: data["ExchangeSegment"],
51
- security_id: data["SecurityId"],
52
- timestamp: data["Timestamp"],
53
- bids: parse_bid_levels(data["Bids"]),
54
- asks: parse_ask_levels(data["Asks"]),
55
- best_bid: data["BestBid"],
56
- best_ask: data["BestAsk"],
57
- spread: calculate_spread(data["BestBid"], data["BestAsk"]),
58
- total_bid_qty: data["TotalBidQty"],
59
- total_ask_qty: data["TotalAskQty"]
60
- }
70
+ def to_binary(data)
71
+ return "".b if data.nil?
72
+
73
+ data.is_a?(Array) ? data.pack("C*") : data.to_s.b
61
74
  end
62
75
 
63
- ##
64
- # Parse depth snapshot message
65
- # @param data [Hash] Message data
66
- # @return [Hash] Parsed depth snapshot
67
- def parse_depth_snapshot(data)
68
- {
69
- type: :depth_snapshot,
70
- symbol: data["Symbol"],
71
- exchange_segment: data["ExchangeSegment"],
72
- security_id: data["SecurityId"],
73
- timestamp: data["Timestamp"],
74
- bids: parse_bid_levels(data["Bids"]),
75
- asks: parse_ask_levels(data["Asks"]),
76
- best_bid: data["BestBid"],
77
- best_ask: data["BestAsk"],
78
- spread: calculate_spread(data["BestBid"], data["BestAsk"]),
79
- total_bid_qty: data["TotalBidQty"],
80
- total_ask_qty: data["TotalAskQty"]
76
+ # @return [Array(Hash, Integer), nil] The packet and the bytes it spans.
77
+ def read_packet(bytes, offset)
78
+ length, code, segment, security_id, tail = bytes.byteslice(offset, HEADER_SIZE).unpack("s<CCl<L<")
79
+ segment_name = Segments.from_code(segment)
80
+
81
+ return [disconnect(bytes, offset, segment_name, security_id), packet_span(length, HEADER_SIZE + 2)] if code == DISCONNECT_CODE
82
+
83
+ unless [BID_CODE, ASK_CODE].include?(code)
84
+ DhanHQ.logger&.warn("[DhanHQ::WS::MarketDepth::Decoder] Unknown response code #{code}; dropping rest of frame")
85
+ return nil
86
+ end
87
+
88
+ rows = @depth_level == 200 ? tail : TWENTY_LEVEL_ROWS
89
+ span = side_span(length, rows)
90
+ available = [(bytes.bytesize - offset - HEADER_SIZE) / ROW_SIZE, rows, (span - HEADER_SIZE) / ROW_SIZE].min
91
+
92
+ packet = {
93
+ type: code == BID_CODE ? :bid : :ask,
94
+ exchange_segment: segment_name,
95
+ security_id: security_id.to_s,
96
+ levels: read_levels(bytes, offset + HEADER_SIZE, available)
81
97
  }
98
+ [packet, span]
82
99
  end
83
100
 
84
- ##
85
- # Parse bid levels
86
- # @param bids [Array] Raw bid levels
87
- # @return [Array<Hash>] Parsed bid levels
88
- def parse_bid_levels(bids)
89
- return [] unless bids.is_a?(Array)
90
-
91
- bids.map do |bid|
92
- {
93
- price: bid["Price"].to_f,
94
- quantity: bid["Quantity"].to_i,
95
- orders: bid["Orders"] || 1
96
- }
97
- end
101
+ # Packet span from the header's length field, falling back to the
102
+ # documented fixed size when the field is absent or implausible.
103
+ def packet_span(length, expected)
104
+ length.to_i >= HEADER_SIZE ? length : expected
98
105
  end
99
106
 
100
- ##
101
- # Parse ask levels
102
- # @param asks [Array] Raw ask levels
103
- # @return [Array<Hash>] Parsed ask levels
104
- def parse_ask_levels(asks)
105
- return [] unless asks.is_a?(Array)
106
-
107
- asks.map do |ask|
108
- {
109
- price: ask["Price"].to_f,
110
- quantity: ask["Quantity"].to_i,
111
- orders: ask["Orders"] || 1
112
- }
107
+ # The docs call the length field "message length of the entire payload
108
+ # packet" without saying whether the 12-byte header is included. A value
109
+ # equal to exactly the rows (rows * 16) is read as payload-only; anything
110
+ # else as the full packet. Both readings stay aligned on stacked frames.
111
+ def side_span(length, rows)
112
+ payload = rows * ROW_SIZE
113
+ return HEADER_SIZE + payload if length.to_i == payload
114
+
115
+ packet_span(length, HEADER_SIZE + payload)
116
+ end
117
+
118
+ def read_levels(bytes, start, count)
119
+ levels = Array.new([count, 0].max) do |i|
120
+ price, quantity, orders = bytes.byteslice(start + (i * ROW_SIZE), ROW_SIZE).unpack("EL<L<")
121
+ { price: price, quantity: quantity, orders: orders }
113
122
  end
123
+ # Unfilled rows of a partially populated book are zeroed on the wire.
124
+ levels.reject { |level| level[:quantity].zero? && level[:price].zero? }
114
125
  end
115
126
 
116
- ##
117
- # Calculate spread between best bid and ask
118
- # @param best_bid [Float, String] Best bid price
119
- # @param best_ask [Float, String] Best ask price
120
- # @return [Float] Spread amount
121
- def calculate_spread(best_bid, best_ask)
122
- bid = best_bid.to_f
123
- ask = best_ask.to_f
124
- return 0.0 if bid.zero? || ask.zero?
125
-
126
- ask - bid
127
+ def disconnect(bytes, offset, segment_name, security_id)
128
+ code = bytes.byteslice(offset + HEADER_SIZE, 2)&.unpack1("s<")
129
+ { type: :disconnect, exchange_segment: segment_name, security_id: security_id.to_s, code: code }
130
+ end
131
+
132
+ def log_trailing(count)
133
+ DhanHQ.logger&.warn("[DhanHQ::WS::MarketDepth::Decoder] Ignoring #{count} trailing byte(s) in depth frame")
127
134
  end
128
135
  end
129
136
  end
data/lib/dhan_hq.rb CHANGED
@@ -62,6 +62,8 @@ module DhanHQ
62
62
  # Eager-load risk utilities (PositionSizer, SLCalculator, TrailManager)
63
63
  # and the pre-execution risk pipeline so they are available immediately.
64
64
  require_relative "DhanHQ/risk"
65
+ require_relative "DhanHQ/risk/limits"
66
+ require_relative "DhanHQ/risk/exposure"
65
67
  require_relative "DhanHQ/risk/checks/trading_permission"
66
68
  require_relative "DhanHQ/risk/checks/asm_gsm"
67
69
  require_relative "DhanHQ/risk/checks/product_support"
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: 4.1.0
4
+ version: 4.1.1
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-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -384,6 +384,8 @@ files:
384
384
  - lib/DhanHQ/risk/checks/product_support.rb
385
385
  - lib/DhanHQ/risk/checks/quantity.rb
386
386
  - lib/DhanHQ/risk/checks/trading_permission.rb
387
+ - lib/DhanHQ/risk/exposure.rb
388
+ - lib/DhanHQ/risk/limits.rb
387
389
  - lib/DhanHQ/risk/pipeline.rb
388
390
  - lib/DhanHQ/skills.rb
389
391
  - lib/DhanHQ/skills/base.rb