DhanHQ 3.4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -1
  3. data/GUIDE.md +40 -0
  4. data/README.md +103 -0
  5. data/docs/CONFIGURATION.md +29 -2
  6. data/docs/ENDPOINTS_AND_SANDBOX.md +83 -38
  7. data/lib/DhanHQ/client.rb +101 -26
  8. data/lib/DhanHQ/concerns/order_audit.rb +26 -3
  9. data/lib/DhanHQ/configuration.rb +178 -27
  10. data/lib/DhanHQ/constants.rb +60 -1
  11. data/lib/DhanHQ/contracts/company_info_contract.rb +24 -0
  12. data/lib/DhanHQ/contracts/market_movers_contract.rb +57 -0
  13. data/lib/DhanHQ/contracts/news_headline_contract.rb +21 -0
  14. data/lib/DhanHQ/contracts/technical_data_contract.rb +25 -0
  15. data/lib/DhanHQ/core/base_api.rb +3 -17
  16. data/lib/DhanHQ/core/base_model.rb +32 -12
  17. data/lib/DhanHQ/dry_run/simulator.rb +3 -2
  18. data/lib/DhanHQ/helpers/attribute_helper.rb +0 -22
  19. data/lib/DhanHQ/helpers/request_helper.rb +13 -3
  20. data/lib/DhanHQ/models/option_chain.rb +7 -0
  21. data/lib/DhanHQ/models/order.rb +7 -7
  22. data/lib/DhanHQ/rate_limiter.rb +34 -40
  23. data/lib/DhanHQ/resources/global_stocks/funds.rb +1 -1
  24. data/lib/DhanHQ/resources/global_stocks/holdings.rb +1 -1
  25. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +1 -1
  26. data/lib/DhanHQ/resources/global_stocks/market_status.rb +1 -1
  27. data/lib/DhanHQ/resources/global_stocks/orders.rb +1 -1
  28. data/lib/DhanHQ/resources/global_stocks/trades.rb +1 -1
  29. data/lib/DhanHQ/resources/market_feed.rb +1 -1
  30. data/lib/DhanHQ/resources/scanx.rb +98 -0
  31. data/lib/DhanHQ/version.rb +1 -1
  32. data/lib/DhanHQ/ws/base_connection.rb +2 -1
  33. data/lib/DhanHQ/ws/client.rb +10 -13
  34. data/lib/DhanHQ/ws/connection.rb +13 -15
  35. data/lib/DhanHQ/ws/market_depth/client.rb +9 -4
  36. data/lib/DhanHQ/ws/orders/client.rb +4 -4
  37. data/lib/DhanHQ/ws/orders/connection.rb +1 -1
  38. data/lib/DhanHQ/ws/registry.rb +10 -6
  39. data/lib/dhan_hq.rb +81 -48
  40. metadata +27 -22
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/data/newsheadline (ScanX live news).
6
+ #
7
+ # categories[] (ALL or any news category), limit (1-50), optional universe
8
+ # (PORTFOLIO / WATCHLIST) and stock_list[] of security ids. dhanClientId is
9
+ # injected by the client layer and is not validated here.
10
+ class NewsHeadlineContract < BaseContract
11
+ params do
12
+ required(:categories).filled(:array, min_size?: 1) do
13
+ each(:str?, included_in?: Constants::ScanX::NEWS_CATEGORIES)
14
+ end
15
+ required(:limit).filled(:integer, included_in?: 1..50)
16
+ optional(:universe).filled(:string, included_in?: Constants::ScanX::NEWS_UNIVERSES)
17
+ optional(:stock_list).filled(:array) { each(:str?) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/data/technical (ScanX technical indicators).
6
+ #
7
+ # securityId (digits), exchangeSegment (NSE_EQ/IDX_I), instrument
8
+ # (INDEX/EQUITY), timeframe (1/5/15/D), indicators[] (e.g. SMA_20, RSI_14).
9
+ class TechnicalDataContract < BaseContract
10
+ params do
11
+ required(:security_id).filled(:string)
12
+ required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::TECHNICAL_SEGMENTS)
13
+ required(:instrument).filled(:string, included_in?: Constants::ScanX::TECHNICAL_INSTRUMENTS)
14
+ required(:timeframe).filled(:string, included_in?: Constants::ScanX::TECHNICAL_TIMEFRAMES)
15
+ required(:indicators).filled(:array, min_size?: 1) do
16
+ each(:str?, included_in?: Constants::ScanX::TECHNICAL_INDICATORS)
17
+ end
18
+ end
19
+
20
+ rule(:security_id) do
21
+ key.failure("must be a numeric security id string") unless value.to_s.match?(/\A\d+\z/)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -17,9 +17,10 @@ module DhanHQ
17
17
 
18
18
  # Initializes the BaseAPI with the appropriate Client instance
19
19
  #
20
+ # @param client [DhanHQ::Client, nil] Optional injected client instance
20
21
  # @param api_type [Symbol] API type (`:order_api`, `:data_api`, `:non_trading_api`)
21
- def initialize(api_type: self.class::API_TYPE)
22
- @client = DhanHQ::Client.new(api_type: api_type)
22
+ def initialize(client: nil, api_type: self.class::API_TYPE)
23
+ @client = client || DhanHQ::Client.new(api_type: api_type)
23
24
  end
24
25
 
25
26
  # Perform a GET request via `Client`
@@ -63,21 +64,6 @@ module DhanHQ
63
64
 
64
65
  private
65
66
 
66
- # Performs an API request.
67
- #
68
- # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
69
- # @param endpoint [String] API endpoint
70
- # @param params [Hash] Request parameters
71
- # @return [Hash, Array] The parsed API response
72
- # @raise [DhanHQ::Error] If an API error occurs.
73
- def request(method, endpoint, params: {})
74
- formatted_params = format_params(endpoint, params)
75
-
76
- response = client.request(method, build_path(endpoint), formatted_params)
77
-
78
- handle_response(response)
79
- end
80
-
81
67
  # Construct the complete API URL
82
68
  #
83
69
  # @param endpoint [String] API endpoint
@@ -40,14 +40,31 @@ module DhanHQ
40
40
  class << self
41
41
  attr_reader :defined_attributes
42
42
 
43
- # Registers the set of attributes for this model
43
+ # Registers the set of attributes for this model. Also defines the
44
+ # snake_case and camelCase readers once per class, so instances stop
45
+ # allocating per-record singleton methods.
44
46
  #
45
47
  # @param args [Array<Symbol, String>] A list of attribute names
46
48
  def attributes(*args)
47
49
  @defined_attributes ||= []
48
- @defined_attributes.concat(args.map(&:to_s))
50
+ new_attrs = args.map(&:to_s)
51
+ @defined_attributes.concat(new_attrs)
52
+ define_accessors(new_attrs)
53
+ @defined_attributes
49
54
  end
50
55
 
56
+ # Defines one snake_case and one camelCase reader per attribute, reading
57
+ # through @attributes so every instance stays in sync. Called from
58
+ # {attributes}; private because it is a declaration helper.
59
+ def define_accessors(attrs)
60
+ attrs.each do |attr|
61
+ camel = attr.to_s.camelize(:lower)
62
+ define_method(attr) { @attributes[attr] }
63
+ define_method(camel) { @attributes[attr] }
64
+ end
65
+ end
66
+ private :define_accessors
67
+
51
68
  # Provide a default API type, can be overridden by child classes
52
69
  #
53
70
  # e.g., def self.api_type; :data_api; end
@@ -64,6 +81,13 @@ module DhanHQ
64
81
  @api ||= BaseAPI.new(api_type: api_type)
65
82
  end
66
83
 
84
+ # Parameter key style used by {#to_request_params}: :camel (camelCase)
85
+ # by default. Models whose API takes TitleCase keys (the option chain)
86
+ # override this to :title.
87
+ def param_key_style
88
+ :camel
89
+ end
90
+
67
91
  ##
68
92
  # Returns the API resource used by collection methods.
69
93
  #
@@ -91,6 +115,8 @@ module DhanHQ
91
115
  # Validate attributes before creating a new instance
92
116
  def validate_attributes(attributes)
93
117
  contract = validation_contract
118
+ return true if contract.nil?
119
+
94
120
  result = contract.call(attributes)
95
121
 
96
122
  raise ArgumentError, "Validation failed: #{result.errors.to_h}" if result.failure?
@@ -132,8 +158,6 @@ module DhanHQ
132
158
  # @param attributes [Hash] The attributes of the resource
133
159
  # @return [DhanHQ::BaseModel, DhanHQ::ErrorObject] The resource or error object
134
160
  def create(attributes)
135
- # validate_params!(attributes, validation_contract)
136
-
137
161
  response = resource.post("", params: attributes)
138
162
  build_from_response(response)
139
163
  end
@@ -242,7 +266,7 @@ module DhanHQ
242
266
  if self.class.defined_attributes&.include?("dhan_client_id") && !attrs[:dhan_client_id] && DhanHQ.configuration.client_id
243
267
  attrs[:dhan_client_id] = DhanHQ.configuration.client_id
244
268
  end
245
- optionchain_api? ? titleize_keys(attrs) : camelize_keys(attrs)
269
+ self.class.param_key_style == :title ? titleize_keys(attrs) : camelize_keys(attrs)
246
270
  end
247
271
 
248
272
  # Identifier inferred from the loaded attributes.
@@ -254,19 +278,15 @@ module DhanHQ
254
278
  id_value&.to_s
255
279
  end
256
280
 
257
- # Dynamically assign attributes as methods
281
+ # Assigns each declared attribute to an instance variable. The accessor
282
+ # methods themselves are defined once per class (see .attributes); the
283
+ # ivars exist for {AttributeHelper#inspect} and debugging.
258
284
  def assign_attributes
259
285
  self.class.defined_attributes&.each do |attr|
260
286
  instance_variable_set(:"@#{attr}", @attributes[attr])
261
- define_singleton_method(attr) { instance_variable_get(:"@#{attr}") }
262
- define_singleton_method(attr.to_s.camelize(:lower)) { instance_variable_get(:"@#{attr}") }
263
287
  end
264
288
  end
265
289
 
266
- def optionchain_api?
267
- self.class.name.include?("OptionChain")
268
- end
269
-
270
290
  # Validate attributes using contract
271
291
  def valid?
272
292
  contract_class = validation_contract || self.class.validation_contract
@@ -25,8 +25,9 @@ module DhanHQ
25
25
  # Extracts a fabricated id out of a request path.
26
26
  ID_PATTERN = /#{Regexp.escape(ID_PREFIX)}[A-Za-z0-9]+/
27
27
 
28
- def initialize(ledger: Ledger.instance)
28
+ def initialize(ledger: Ledger.instance, config: nil)
29
29
  @ledger = ledger
30
+ @config = config
30
31
  end
31
32
 
32
33
  # Whether this request should be answered locally.
@@ -35,7 +36,7 @@ module DhanHQ
35
36
  # @param path [String, nil] Request path
36
37
  # @return [Boolean]
37
38
  def simulates?(method, path)
38
- return false unless DhanHQ.configuration&.dry_run?
39
+ return false unless (@config || DhanHQ.configuration)&.dry_run?
39
40
 
40
41
  WritePaths.mutating?(method, path) || replayable_read?(method, path)
41
42
  end
@@ -69,27 +69,5 @@ module DhanHQ
69
69
  instance_vars = self.class.defined_attributes.map { |attr| "#{attr}: #{instance_variable_get(:"@#{attr}")}" }
70
70
  "#<#{self.class.name} #{instance_vars.join(", ")}>"
71
71
  end
72
-
73
- # def format_params(path, params)
74
- # return params unless params.is_a?(Hash)
75
-
76
- # if optionchain_api?(path)
77
- # titleize_keys(params)
78
- # else
79
- # camelize_keys(params)
80
- # end
81
- # end
82
-
83
- # def camelize_keys(hash)
84
- # hash.transform_keys { |key| key.to_s.camelize(:lower) }
85
- # end
86
-
87
- # def titleize_keys(hash)
88
- # hash.transform_keys { |key| key.to_s.titleize.delete(" ") }
89
- # end
90
-
91
- # def optionchain_api?(path)
92
- # path.include?("/optionchain")
93
- # end
94
72
  end
95
73
  end
@@ -21,6 +21,16 @@ module DhanHQ
21
21
 
22
22
  private
23
23
 
24
+ def current_config
25
+ if respond_to?(:config) && config
26
+ config
27
+ elsif respond_to?(:client) && client.respond_to?(:config) && client.config
28
+ client.config
29
+ else
30
+ DhanHQ.configuration
31
+ end
32
+ end
33
+
24
34
  # Dynamically builds headers for each request.
25
35
  #
26
36
  # @param path [String] The API endpoint path.
@@ -41,7 +51,7 @@ module DhanHQ
41
51
 
42
52
  # Add client-id for DATA APIs (now including sandbox profile/funds)
43
53
  if data_api?(path)
44
- client_id = DhanHQ.configuration&.client_id
54
+ client_id = current_config&.client_id
45
55
  unless client_id
46
56
  raise DhanHQ::InvalidAuthenticationError,
47
57
  "client_id is required for DATA APIs but not set. Please configure DhanHQ with DHAN_CLIENT_ID."
@@ -53,7 +63,7 @@ module DhanHQ
53
63
  end
54
64
 
55
65
  def resolved_access_token
56
- DhanHQ.configuration&.resolved_access_token
66
+ current_config&.resolved_access_token
57
67
  end
58
68
 
59
69
  # Determines if the API path requires a `client-id` header.
@@ -80,7 +90,7 @@ module DhanHQ
80
90
 
81
91
  out = payload
82
92
  if path && %i[post put patch].include?(method)
83
- client_id = DhanHQ.configuration&.client_id
93
+ client_id = current_config&.client_id
84
94
  needs_client_id = data_api?(path) || payload_requires_dhan_client_id?(path)
85
95
  if client_id && needs_client_id && !payload.key?(:dhanClientId) && !payload.key?("dhanClientId")
86
96
  out = payload.dup
@@ -55,6 +55,13 @@ module DhanHQ
55
55
  end
56
56
 
57
57
  class << self
58
+ ##
59
+ # The option chain API takes TitleCase keys (e.g. "UnderlyingScripValue");
60
+ # see BaseModel.param_key_style.
61
+ def param_key_style
62
+ :title
63
+ end
64
+
58
65
  ##
59
66
  # Provides a shared instance of the OptionChain resource.
60
67
  #
@@ -491,12 +491,12 @@ module DhanHQ
491
491
  # order = DhanHQ::Models::Order.find("112111182045")
492
492
  # order.modify(validity: "IOC")
493
493
  #
494
- # @raise [RuntimeError] If order ID is missing
494
+ # @raise [DhanHQ::OrderError] If order ID is missing
495
495
  # @raise [DhanHQ::ValidationError] If validation fails for any parameter
496
496
  # @raise [DhanHQ::ModificationLimitError] If this instance has already been modified 25 times (Dhan API cap)
497
497
  # @note Count is per Order instance in this process; a fresh find() resets it.
498
498
  def modify(new_params)
499
- raise "Order ID is required to modify an order" unless id
499
+ raise DhanHQ::OrderError, "Order ID is required to modify an order" unless id
500
500
 
501
501
  count = @modification_count || 0
502
502
  if count >= Constants::RateLimit::ORDER_MODIFICATIONS_PER_ORDER
@@ -537,9 +537,9 @@ module DhanHQ
537
537
  # puts "Failed to cancel order"
538
538
  # end
539
539
  #
540
- # @raise [RuntimeError] If order ID is missing
540
+ # @raise [DhanHQ::OrderError] If order ID is missing
541
541
  def cancel
542
- raise "Order ID is required to cancel an order" unless id
542
+ raise DhanHQ::OrderError, "Order ID is required to cancel an order" unless id
543
543
 
544
544
  DhanHQ.logger&.info("[DhanHQ::Models::Order] Cancelling order #{id}")
545
545
  response = self.class.resource.cancel(id)
@@ -566,9 +566,9 @@ module DhanHQ
566
566
  # puts "Updated status: #{order.order_status}"
567
567
  # puts "Filled: #{order.filled_qty}/#{order.quantity}"
568
568
  #
569
- # @raise [RuntimeError] If order ID is missing
569
+ # @raise [DhanHQ::OrderError] If order ID is missing
570
570
  def refresh
571
- raise "Order ID is required to refresh an order" unless id
571
+ raise DhanHQ::OrderError, "Order ID is required to refresh an order" unless id
572
572
 
573
573
  self.class.find(id)
574
574
  end
@@ -603,7 +603,7 @@ module DhanHQ
603
603
  alias delete destroy
604
604
 
605
605
  def slice_order(params)
606
- raise "Order ID is required to slice an order" unless id
606
+ raise DhanHQ::OrderError, "Order ID is required to slice an order" unless id
607
607
 
608
608
  normalized = snake_case(params)
609
609
  normalized[:dhan_client_id] ||= DhanHQ.configuration&.client_id
@@ -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.
@@ -49,7 +49,7 @@ module DhanHQ
49
49
  #
50
50
  # @return [MarketFeed]
51
51
  def quote_resource
52
- @quote_resource ||= self.class.new(api_type: :quote_api)
52
+ @quote_resource ||= self.class.new(client: DhanHQ::Client.new(config: client.config, api_type: :quote_api))
53
53
  end
54
54
  end
55
55
  end
@@ -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 = "3.4.0"
5
+ VERSION = "4.1.0"
6
6
  end
@@ -23,8 +23,9 @@ module DhanHQ
23
23
  # @param options [Hash] Connection options
24
24
  # @option options [Integer] :max_backoff Maximum backoff time (default: 90)
25
25
  # @option options [Integer] :cool_off_429 Cool off time for 429 errors (default: 60)
26
- def initialize(url:, **options)
26
+ def initialize(url:, config: nil, **options)
27
27
  @url = url
28
+ @config = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
28
29
  @callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
29
30
  @started = Concurrent::AtomicBoolean.new(false)
30
31
  @stop = false