DhanHQ 4.0.0 → 4.1.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.
@@ -5,11 +5,20 @@ require "concurrent"
5
5
  module DhanHQ
6
6
  # Coarse-grained in-memory throttler matching the platform rate limits.
7
7
  class RateLimiter
8
- # Per-interval thresholds keyed by API type, matching the published
9
- # DhanHQ rate-limit table (Order APIs: 10/sec, 100,000/day;
10
- # Data APIs: 5/sec, 7,000/day; Market Quote: 1/sec; Option Chain: 1 per 3 sec).
8
+ # Per-interval thresholds keyed by API type.
9
+ #
10
+ # Values are deliberately conservative client-side budgets: they follow this
11
+ # repo's own documented operational limits (docs/DATA_API_PARAMETERS.md — market
12
+ # feed 1/s, option chain 1 per 3s; skills/dhanhq-ruby/references/option-chain.md),
13
+ # NOT the platform's published table, so the SDK stays well inside whatever
14
+ # per-account enforcement the broker applies. Per-minute caps are not enforced
15
+ # yet (all per_minute values are infinite except option chain); see the 4.1.0
16
+ # review notes for the open question.
17
+ # Global Stocks shares the order-API limits on its own tier so it can be
18
+ # routed independently (it always stays on the production host).
11
19
  RATE_LIMITS = {
12
20
  order_api: { per_second: 10, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 100_000 },
21
+ global_stocks_api: { per_second: 10, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 100_000 },
13
22
  data_api: { per_second: 5, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 7_000 },
14
23
  quote_api: { per_second: 1, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: Float::INFINITY },
15
24
  option_chain: { per_second: 1.0 / 3, per_minute: 20, per_hour: 600, per_day: 4800 },
@@ -17,6 +26,9 @@ module DhanHQ
17
26
  per_day: Float::INFINITY }
18
27
  }.freeze
19
28
 
29
+ # Seconds to wait between counter resets, per interval.
30
+ RESET_INTERVALS = { per_minute: 60, per_hour: 3600, per_day: 86_400 }.freeze
31
+
20
32
  # Thread-safe shared rate limiters per API type
21
33
  @shared_limiters = Concurrent::Map.new
22
34
  @mutexes = Concurrent::Map.new
@@ -56,9 +68,7 @@ module DhanHQ
56
68
 
57
69
  sleep_time = 3 - (Time.now - last_request_time)
58
70
  if sleep_time.positive?
59
- if ENV["DHAN_DEBUG"] == "true"
60
- puts "Sleeping for #{sleep_time.round(2)} seconds due to option_chain rate limit"
61
- end
71
+ DhanHQ.logger&.debug("[DhanHQ::RateLimiter] Sleeping #{sleep_time.round(2)}s (option_chain limit)")
62
72
  sleep(sleep_time)
63
73
  end
64
74
 
@@ -101,6 +111,9 @@ module DhanHQ
101
111
  end
102
112
  end
103
113
 
114
+ # NOTE: @request_times is only touched inside #throttle!, which holds the
115
+ # per-tier mutex, so the plain Array needs no synchronization of its own.
116
+
104
117
  private
105
118
 
106
119
  # Gets or creates a mutex for this API type for thread-safe throttling
@@ -140,47 +153,28 @@ module DhanHQ
140
153
  end
141
154
  end
142
155
 
143
- # Spawns background threads to reset counters after each interval elapses.
156
+ # Spawns one reset thread per interval whose limit is finite. Intervals whose
157
+ # limit is Float::INFINITY never block, so resetting them is a no-op and no
158
+ # thread is created (quote and non-trading tiers end up with zero threads).
159
+ # Threads die with the process; #shutdown exists for explicit teardown.
144
160
  def start_cleanup_threads
145
161
  @cleanup_threads = []
146
162
  @shutdown = Concurrent::AtomicBoolean.new(false)
147
163
 
148
- # Don't create per_second cleanup thread - we handle it with timestamps
149
- @cleanup_threads << Thread.new do
150
- loop do
151
- break if @shutdown.true?
152
-
153
- sleep(60)
154
- break if @shutdown.true?
164
+ RESET_INTERVALS.each do |interval, seconds|
165
+ limit = RATE_LIMITS[@api_type][interval]
166
+ next if limit.nil? || limit >= Float::INFINITY
155
167
 
156
- mutex.synchronize do
157
- @buckets[:per_minute]&.value = 0
158
- end
159
- end
160
- end
168
+ @cleanup_threads << Thread.new do
169
+ loop do
170
+ break if @shutdown.true?
161
171
 
162
- @cleanup_threads << Thread.new do
163
- loop do
164
- break if @shutdown.true?
172
+ sleep(seconds)
173
+ break if @shutdown.true?
165
174
 
166
- sleep(3600)
167
- break if @shutdown.true?
168
-
169
- mutex.synchronize do
170
- @buckets[:per_hour]&.value = 0
171
- end
172
- end
173
- end
174
-
175
- @cleanup_threads << Thread.new do
176
- loop do
177
- break if @shutdown.true?
178
-
179
- sleep(86_400)
180
- break if @shutdown.true?
181
-
182
- mutex.synchronize do
183
- @buckets[:per_day]&.value = 0
175
+ mutex.synchronize do
176
+ @buckets[interval]&.value = 0
177
+ end
184
178
  end
185
179
  end
186
180
  end
@@ -10,7 +10,7 @@ module DhanHQ
10
10
  # Global Stocks funds are held in USD and are reported separately from the
11
11
  # domestic INR fund limit exposed by {DhanHQ::Resources::Funds}.
12
12
  class Funds < BaseAPI
13
- API_TYPE = :order_api
13
+ API_TYPE = :global_stocks_api
14
14
  HTTP_PATH = "/v2/globalstocks/fundlimit"
15
15
 
16
16
  # Retrieves the authenticated user's US stock fund limit details.
@@ -7,7 +7,7 @@ module DhanHQ
7
7
  #
8
8
  # GET /v2/globalstocks/holdings
9
9
  class Holdings < BaseAPI
10
- API_TYPE = :order_api
10
+ API_TYPE = :global_stocks_api
11
11
  HTTP_PATH = "/v2/globalstocks/holdings"
12
12
 
13
13
  # Retrieves the authenticated user's US stock holdings.
@@ -11,7 +11,7 @@ module DhanHQ
11
11
  # Both endpoints take the same request body. The API documents +price+ and
12
12
  # +quantity+ as strings, so numeric input is stringified before sending.
13
13
  class MarginCalculator < BaseAPI
14
- API_TYPE = :non_trading_api
14
+ API_TYPE = :global_stocks_api
15
15
  HTTP_PATH = "/v2/globalstocks"
16
16
 
17
17
  # Calculates the margin required for a Global Stocks order.
@@ -7,7 +7,7 @@ module DhanHQ
7
7
  #
8
8
  # GET /v2/globalstocks/marketstatus
9
9
  class MarketStatus < BaseAPI
10
- API_TYPE = :non_trading_api
10
+ API_TYPE = :global_stocks_api
11
11
  HTTP_PATH = "/v2/globalstocks/marketstatus"
12
12
 
13
13
  # Retrieves the current US market status and session timings.
@@ -26,7 +26,7 @@ module DhanHQ
26
26
  class Orders < BaseAPI
27
27
  include DhanHQ::Concerns::OrderAudit
28
28
 
29
- API_TYPE = :order_api
29
+ API_TYPE = :global_stocks_api
30
30
  HTTP_PATH = "/v2/globalstocks/orders"
31
31
 
32
32
  # Places a new Global Stocks order.
@@ -8,7 +8,7 @@ module DhanHQ
8
8
  # GET /v2/globalstocks/trades
9
9
  # GET /v2/globalstocks/trades/{security-id}
10
10
  class Trades < BaseAPI
11
- API_TYPE = :order_api
11
+ API_TYPE = :global_stocks_api
12
12
  HTTP_PATH = "/v2/globalstocks/trades"
13
13
 
14
14
  # Retrieves the current trading day's executed Global Stocks trades.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ ##
6
+ # Resource for the ScanX data API endpoints (POST /v2/data/*).
7
+ #
8
+ # Covers company fundamentals, market movers, live news headlines and
9
+ # server-side technical indicators. These are market-data endpoints, so in
10
+ # hybrid mode they keep hitting the production host.
11
+ class ScanX < BaseAPI
12
+ # ScanX requests hit the data API tier (5 req/sec, 7,000/day).
13
+ API_TYPE = :data_api
14
+ # Root path for the ScanX data endpoints.
15
+ HTTP_PATH = "/v2/data"
16
+
17
+ ##
18
+ # POST /v2/data/companyinfo
19
+ # Company overview, valuation/profitability ratios and shareholding
20
+ # pattern for a single equity instrument.
21
+ #
22
+ # @param params [Hash] Example:
23
+ # {
24
+ # security_id: "1333",
25
+ # exchange_segment: "NSE_EQ",
26
+ # instrument: "EQUITY",
27
+ # metrics: ["CO", "RATIOS", "SHP"]
28
+ # }
29
+ # @return [HashWithIndifferentAccess]
30
+ def company_info(params)
31
+ validate_with!(Contracts::CompanyInfoContract, params)
32
+ post("/companyinfo", params: params)
33
+ end
34
+
35
+ ##
36
+ # POST /v2/data/marketmovers
37
+ # Top options, futures and stocks ranked by open interest, volume or
38
+ # price movement.
39
+ #
40
+ # @param params [Hash] Example:
41
+ # {
42
+ # exchange_segment: "NSE_FNO",
43
+ # instrument: ["OPTIDX", "OPTSTK"],
44
+ # category: "HIGHEST_OI",
45
+ # expiry: "2026-10-29",
46
+ # limit: 10
47
+ # }
48
+ # For EQUITY rankings pass +universe:+ (e.g. "NIFTY_50") instead of expiry.
49
+ # @return [HashWithIndifferentAccess]
50
+ def market_movers(params)
51
+ validate_with!(Contracts::MarketMoversContract, params)
52
+ post("/marketmovers", params: params)
53
+ end
54
+
55
+ ##
56
+ # POST /v2/data/newsheadline
57
+ # Latest news headlines, filterable by category and stock (max 50 items).
58
+ # +dhanClientId+ is injected automatically from the configuration.
59
+ #
60
+ # @param params [Hash] Example:
61
+ # { categories: ["ALL"], limit: 10, stock_list: ["1333"] }
62
+ # @return [HashWithIndifferentAccess]
63
+ def news_headlines(params)
64
+ validate_with!(Contracts::NewsHeadlineContract, params)
65
+ post("/newsheadline", params: params)
66
+ end
67
+
68
+ ##
69
+ # POST /v2/data/technical
70
+ # Server-side technical indicator values for an instrument. Only the
71
+ # requested indicators are computed and returned.
72
+ #
73
+ # @param params [Hash] Example:
74
+ # {
75
+ # security_id: "1333",
76
+ # exchange_segment: "NSE_EQ",
77
+ # instrument: "EQUITY",
78
+ # timeframe: "D",
79
+ # indicators: ["SMA_20", "RSI_14", "MACD_HIST"]
80
+ # }
81
+ # @return [HashWithIndifferentAccess]
82
+ def technical_data(params)
83
+ validate_with!(Contracts::TechnicalDataContract, params)
84
+ post("/technical", params: params)
85
+ end
86
+
87
+ private
88
+
89
+ # Runs the given contract and raises DhanHQ::ValidationError on failure.
90
+ def validate_with!(contract_class, params)
91
+ result = contract_class.new.call(snake_case(params))
92
+ return if result.success?
93
+
94
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "4.0.0"
5
+ VERSION = "4.1.0"
6
6
  end
@@ -35,8 +35,10 @@ module DhanHQ
35
35
  token = @config.resolved_access_token
36
36
  raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
37
37
 
38
- cid = @config.client_id or raise "DhanHQ.client_id not set"
39
- ver = (@config.respond_to?(:ws_version) && @config.ws_version) || 2
38
+ cid = @config.client_id
39
+ raise DhanHQ::AuthenticationError, "client_id is not set (configure DhanHQ.client_id)" if cid.nil? || cid.to_s.empty?
40
+
41
+ ver = (@config.respond_to?(:ws_version) && @config.ws_version) || 2
40
42
  base = url || @config.ws_market_feed_url
41
43
  @url = base.include?("?") ? base : "#{base}?version=#{ver}&token=#{token}&clientId=#{cid}&authType=2"
42
44
  end
@@ -54,7 +56,7 @@ module DhanHQ
54
56
  emit(:tick, tick) if tick
55
57
  end
56
58
  Registry.register(self)
57
- install_at_exit_once!
59
+ self.class.install_at_exit_hook!
58
60
  @conn.start
59
61
  self
60
62
  end
@@ -295,13 +297,6 @@ module DhanHQ
295
297
  rescue StandardError => e
296
298
  DhanHQ.logger&.error("[DhanHQ::WS::Client] Error in event handler for #{event}: #{e.class} #{e.message}")
297
299
  end
298
-
299
- def install_at_exit_once!
300
- return if defined?(@at_exit_installed) && @at_exit_installed
301
-
302
- @at_exit_installed = true
303
- at_exit { Registry.stop_all }
304
- end
305
300
  end
306
301
  end
307
302
  end
@@ -9,9 +9,14 @@ module DhanHQ
9
9
  # Low-level wrapper responsible for establishing and maintaining the raw
10
10
  # WebSocket connection to the streaming API.
11
11
  class Connection
12
- SUB_CODES = { ticker: 15, quote: 15, full: 15 }.freeze # All use RequestCode 15 per official API
13
- # Request codes used when unsubscribing from feeds.
14
- UNSUB_CODES = { ticker: 12, quote: 12, full: 12 }.freeze # Use disconnect code 12 for unsubscribe
12
+ # Feed request codes per the official Annexure "Feed Request Code" table
13
+ # (dhanhq.co/docs/v2/annexure#feed-request-code): 15/16 ticker, 17/18 quote,
14
+ # 21/22 full. RequestCode 12 is the whole-connection Disconnect Feed frame
15
+ # and is reserved for #send_disconnect.
16
+ SUB_CODES = { ticker: 15, quote: 17, full: 21 }.freeze
17
+ # Unsubscribe codes per the same table: 16/18/22. Never 12 — that is the
18
+ # whole-connection disconnect, not a per-instrument unsubscribe.
19
+ UNSUB_CODES = { ticker: 16, quote: 18, full: 22 }.freeze
15
20
 
16
21
  COOL_OFF_429 = 60 # seconds to cool off on 429
17
22
  MAX_BACKOFF = 90 # cap exponential backoff
@@ -219,7 +224,7 @@ module DhanHQ
219
224
  subs, unsubs = cmds.partition { |c| c.op == :sub }
220
225
 
221
226
  unless subs.empty?
222
- list = uniq(flatten(subs.map(&:payload)))
227
+ list = uniq(subs.map(&:payload).flatten)
223
228
  new_only = @state.want_sub(list)
224
229
  unless new_only.empty?
225
230
  send_sub(new_only)
@@ -229,7 +234,7 @@ module DhanHQ
229
234
 
230
235
  return if unsubs.empty?
231
236
 
232
- list = uniq(flatten(unsubs.map(&:payload)))
237
+ list = uniq(unsubs.map(&:payload).flatten)
233
238
  exist_only = @state.want_unsub(list)
234
239
  return if exist_only.empty?
235
240
 
@@ -267,17 +272,10 @@ module DhanHQ
267
272
  DhanHQ.logger&.debug("[DhanHQ::WS] send_disconnect error #{e.class}: #{e.message}")
268
273
  end
269
274
 
270
- def flatten(a) = a.flatten
271
-
272
275
  def uniq(list)
273
- seen = {}
274
- list.each_with_object([]) do |i, out|
275
- k = "#{i[:ExchangeSegment]}:#{i[:SecurityId]}"
276
- next if seen[k]
277
-
278
- out << i
279
- seen[k] = true
280
- end
276
+ # Composite key: the same instrument can arrive via repeated subscribe
277
+ # calls; Array#uniq with a block keeps the first occurrence.
278
+ list.uniq { |i| [i[:ExchangeSegment], i[:SecurityId]] }
281
279
  end
282
280
  end
283
281
  end
@@ -13,8 +13,12 @@ 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
20
  SUBSCRIBE_REQUEST_CODE = 23
17
- UNSUBSCRIBE_REQUEST_CODE = 12
21
+ UNSUBSCRIBE_REQUEST_CODE = 24
18
22
 
19
23
  ##
20
24
  # Initialize Market Depth WebSocket client
@@ -9,13 +9,14 @@ module DhanHQ
9
9
  # disconnected when required.
10
10
  class Registry
11
11
  @clients = []
12
+ @mutex = Mutex.new
12
13
  class << self
13
14
  # Registers a client instance with the registry.
14
15
  #
15
16
  # @param client [DhanHQ::WS::Client]
16
17
  # @return [void]
17
18
  def register(client)
18
- @clients << client unless @clients.include?(client)
19
+ @mutex.synchronize { @clients << client unless @clients.include?(client) }
19
20
  end
20
21
 
21
22
  # Removes a client from the registry.
@@ -23,18 +24,21 @@ module DhanHQ
23
24
  # @param client [DhanHQ::WS::Client]
24
25
  # @return [void]
25
26
  def unregister(client)
26
- @clients.delete(client)
27
+ @mutex.synchronize { @clients.delete(client) }
27
28
  end
28
29
 
29
30
  # Stops and removes all registered clients.
30
31
  #
31
32
  # @return [void]
32
33
  def stop_all
33
- @clients.dup.each do |c|
34
- c.stop
35
- rescue StandardError
34
+ @mutex.synchronize do
35
+ @clients.dup.each do |c|
36
+ c.stop
37
+ rescue StandardError => e
38
+ DhanHQ.logger&.debug("[DhanHQ::WS::Registry] stop failed: #{e.class} #{e.message}")
39
+ end
40
+ @clients.clear
36
41
  end
37
- @clients.clear
38
42
  end
39
43
  end
40
44
  end
data/lib/dhan_hq.rb CHANGED
@@ -44,6 +44,9 @@ module DhanHQ
44
44
  # "dhan_hq"` — it only worked via exe/dhanhq-mcp and lib/dhan_hq/mcp.rb, which
45
45
  # require_relative the file directly instead of going through the autoloader.
46
46
  "mcp" => "MCP",
47
+ # resources/scanx.rb defines DhanHQ::Resources::ScanX; without this Zeitwerk
48
+ # looks for DhanHQ::Resources::Scanx and fails on the first reference.
49
+ "scanx" => "ScanX",
47
50
  "ws" => "WS"
48
51
  )
49
52
  LOADER.push_dir(File.join(__dir__, "DhanHQ"), namespace: self)
@@ -84,8 +87,6 @@ module DhanHQ
84
87
  require_relative "DhanHQ/skills/builtin/market_data_summarizer"
85
88
  DhanHQ::Skills::Registry.load_builtins
86
89
 
87
- class Error < StandardError; end
88
-
89
90
  class << self
90
91
  # Default REST API host used when no custom base URL is provided.
91
92
  #
@@ -244,6 +245,10 @@ module DhanHQ
244
245
  def fetch_token_endpoint(url, bearer_token, label)
245
246
  conn = ::Faraday.new(url: url) do |c|
246
247
  c.request :url_encoded
248
+ # Bounded timeouts, same defaults as Client#build_connection; without
249
+ # them a stalled token endpoint would hang the caller forever.
250
+ c.options.timeout = ENV.fetch("DHAN_READ_TIMEOUT", 30).to_i
251
+ c.options.open_timeout = ENV.fetch("DHAN_CONNECT_TIMEOUT", 10).to_i
247
252
  c.adapter ::Faraday.default_adapter
248
253
  end
249
254
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: DhanHQ
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shubham Taywade
@@ -241,6 +241,7 @@ files:
241
241
  - lib/DhanHQ/constants.rb
242
242
  - lib/DhanHQ/contracts/alert_order_contract.rb
243
243
  - lib/DhanHQ/contracts/base_contract.rb
244
+ - lib/DhanHQ/contracts/company_info_contract.rb
244
245
  - lib/DhanHQ/contracts/edis_contract.rb
245
246
  - lib/DhanHQ/contracts/expired_options_data_contract.rb
246
247
  - lib/DhanHQ/contracts/forever_order_contract.rb
@@ -254,15 +255,18 @@ files:
254
255
  - lib/DhanHQ/contracts/intraday_historical_data_contract.rb
255
256
  - lib/DhanHQ/contracts/margin_calculator_contract.rb
256
257
  - lib/DhanHQ/contracts/market_feed_contract.rb
258
+ - lib/DhanHQ/contracts/market_movers_contract.rb
257
259
  - lib/DhanHQ/contracts/modify_order_contract.rb
258
260
  - lib/DhanHQ/contracts/multi_order_contract.rb
259
261
  - lib/DhanHQ/contracts/multi_scrip_margin_calc_request_contract.rb
262
+ - lib/DhanHQ/contracts/news_headline_contract.rb
260
263
  - lib/DhanHQ/contracts/option_chain_contract.rb
261
264
  - lib/DhanHQ/contracts/order_contract.rb
262
265
  - lib/DhanHQ/contracts/place_order_contract.rb
263
266
  - lib/DhanHQ/contracts/pnl_based_exit_contract.rb
264
267
  - lib/DhanHQ/contracts/position_conversion_contract.rb
265
268
  - lib/DhanHQ/contracts/slice_order_contract.rb
269
+ - lib/DhanHQ/contracts/technical_data_contract.rb
266
270
  - lib/DhanHQ/contracts/trade_by_order_id_contract.rb
267
271
  - lib/DhanHQ/contracts/trade_contract.rb
268
272
  - lib/DhanHQ/contracts/trade_history_contract.rb
@@ -363,6 +367,7 @@ files:
363
367
  - lib/DhanHQ/resources/pnl_exit.rb
364
368
  - lib/DhanHQ/resources/positions.rb
365
369
  - lib/DhanHQ/resources/profile.rb
370
+ - lib/DhanHQ/resources/scanx.rb
366
371
  - lib/DhanHQ/resources/statements.rb
367
372
  - lib/DhanHQ/resources/super_orders.rb
368
373
  - lib/DhanHQ/resources/trader_control.rb