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
data/lib/DhanHQ/client.rb CHANGED
@@ -42,42 +42,82 @@ module DhanHQ
42
42
  ].freeze
43
43
  private_constant :RETRYABLE_TRANSPORT_ERRORS
44
44
 
45
- attr_reader :token_manager
45
+ attr_reader :config, :token_manager, :api_type
46
46
 
47
- # Initializes a new DhanHQ Client.
48
- #
49
- # Establishes state and checks its one invariant; the HTTP connection is opened
50
- # lazily on first use, so constructing a client does no network setup.
51
- #
52
- # @example Create a new client:
53
- # client = DhanHQ::Client.new(api_type: :order_api)
47
+ # Initializes a new DhanHQ Client with instance-level configuration and rate limiting.
54
48
  #
49
+ # @param config_or_attrs [DhanHQ::Configuration, Hash, nil] Optional configuration or attribute hash
50
+ # @param config [DhanHQ::Configuration, nil] Optional keyword configuration
55
51
  # @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
56
- # @return [DhanHQ::Client] A new client instance.
57
- # @raise [DhanHQ::Error] If the rate limiter cannot be resolved for this API type.
58
- def initialize(api_type:)
59
- DhanHQ.ensure_configuration!
60
- # Shared per API type, so separate clients coordinate against one limit.
52
+ # @param mode [Symbol, nil] Optional environment override (`:live`, `:sandbox`, `:hybrid`)
53
+ # for this client. When combined with +config:+ the configuration is forked,
54
+ # so a shared/global configuration is never mutated.
55
+ # @param attrs [Hash] Additional keyword configuration attributes
56
+ def initialize(config_or_attrs = nil, config: nil, api_type: :order_api, mode: nil, **attrs)
57
+ attrs = attrs.merge(mode: mode) if mode
58
+ @config = resolve_config(config_or_attrs, config, attrs)
59
+ @api_type = api_type
61
60
  @rate_limiter = RateLimiter.for(api_type)
62
61
 
63
62
  raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
64
63
  end
65
64
 
66
65
  # The Faraday connection used for HTTP requests.
67
- #
68
- # Built on first use and rebuilt whenever the configured base URL changes — which
69
- # happens when sandbox mode is toggled, or when a token endpoint hands back a
70
- # different host mid-process.
66
+ # Built on first use and rebuilt whenever the base URL for this client's API
67
+ # tier changes — including a mid-flight mode switch.
71
68
  #
72
69
  # @return [Faraday::Connection]
73
70
  def connection
74
- current_url = DhanHQ.configuration.base_url
71
+ current_url = @config.base_url_for(@api_type)
75
72
  return @connection if @connection && @last_base_url == current_url
76
73
 
77
74
  @last_base_url = current_url
78
75
  @connection = build_connection(current_url)
79
76
  end
80
77
 
78
+ # Resource accessors bound to this client instance. Each resource gets the client
79
+ # matching its own API_TYPE, so e.g. option chain calls throttle at 1 per 3 sec
80
+ # instead of borrowing the order client's 10/sec limiter.
81
+ def orders = @orders ||= Resources::Orders.new(client: client_for(Resources::Orders::API_TYPE))
82
+ def market_feed = @market_feed ||= Resources::MarketFeed.new(client: client_for(Resources::MarketFeed::API_TYPE))
83
+ def positions = @positions ||= Resources::Positions.new(client: client_for(Resources::Positions::API_TYPE))
84
+ def holdings = @holdings ||= Resources::Holdings.new(client: client_for(Resources::Holdings::API_TYPE))
85
+ def historical_data = @historical_data ||= Resources::HistoricalData.new(client: client_for(Resources::HistoricalData::API_TYPE))
86
+ def option_chain = @option_chain ||= Resources::OptionChain.new(client: client_for(Resources::OptionChain::API_TYPE))
87
+ def funds = @funds ||= Resources::Funds.new(client: client_for(Resources::Funds::API_TYPE))
88
+ def trades = @trades ||= Resources::Trades.new(client: client_for(Resources::Trades::API_TYPE))
89
+ def forever_orders = @forever_orders ||= Resources::ForeverOrders.new(client: client_for(Resources::ForeverOrders::API_TYPE))
90
+ def alert_orders = @alert_orders ||= Resources::AlertOrders.new(client: client_for(Resources::AlertOrders::API_TYPE))
91
+ def iceberg_orders = @iceberg_orders ||= Resources::IcebergOrders.new(client: client_for(Resources::IcebergOrders::API_TYPE))
92
+ def super_orders = @super_orders ||= Resources::SuperOrders.new(client: client_for(Resources::SuperOrders::API_TYPE))
93
+ def twap_orders = @twap_orders ||= Resources::TwapOrders.new(client: client_for(Resources::TwapOrders::API_TYPE))
94
+ def statements = @statements ||= Resources::Statements.new(client: client_for(Resources::Statements::API_TYPE))
95
+ def kill_switch = @kill_switch ||= Resources::KillSwitch.new(client: client_for(Resources::KillSwitch::API_TYPE))
96
+ def pnl_exit = @pnl_exit ||= Resources::PnlExit.new(client: client_for(Resources::PnlExit::API_TYPE))
97
+ def trader_control = @trader_control ||= Resources::TraderControl.new(client: client_for(Resources::TraderControl::API_TYPE))
98
+ def ip_setup = @ip_setup ||= Resources::IPSetup.new(client: client_for(Resources::IPSetup::API_TYPE))
99
+ def edis = @edis ||= Resources::Edis.new(client: client_for(Resources::Edis::API_TYPE))
100
+ def scanx = @scanx ||= Resources::ScanX.new(client: client_for(Resources::ScanX::API_TYPE))
101
+
102
+ # The effective environment (+:live+ or +:sandbox+) for requests issued by
103
+ # this client, resolved from its API tier and the configured mode.
104
+ #
105
+ # @return [Symbol]
106
+ def environment
107
+ @config.environment_for(@api_type)
108
+ end
109
+
110
+ # WebSocket streams bound to this client instance configuration
111
+ def ws_market_feed(mode: :ticker, url: nil, &block)
112
+ WS::Client.new(mode: mode, url: url, config: @config).tap do |ws|
113
+ ws.start if block_given?
114
+ ws.on(:tick, &block) if block_given?
115
+ end
116
+ end
117
+
118
+ def ws_order_update = WS::Orders::Client.new(config: @config)
119
+ def ws_market_depth = WS::MarketDepth::Client.new(config: @config)
120
+
81
121
  # Sends an HTTP request to the API with automatic retry for transient errors.
82
122
  #
83
123
  # @param method [Symbol] The HTTP method (`:get`, `:post`, `:put`, `:delete`)
@@ -93,8 +133,6 @@ module DhanHQ
93
133
  @token_manager&.ensure_valid_token!
94
134
  @rate_limiter.throttle!
95
135
 
96
- # A non-idempotent write is not safely retryable: the API has no idempotency
97
- # key, so a request that timed out may already have reached the exchange.
98
136
  effective_retries = retryable_write?(method, path) ? retries : 0
99
137
 
100
138
  with_auth_retry do
@@ -112,10 +150,9 @@ module DhanHQ
112
150
  yield
113
151
  rescue DhanHQ::InvalidAuthenticationError, DhanHQ::InvalidTokenError,
114
152
  DhanHQ::TokenExpiredError, DhanHQ::AuthenticationFailedError => e
115
- config = DhanHQ.configuration
116
- raise unless config&.access_token_provider
153
+ raise unless @config&.access_token_provider
117
154
 
118
- config.on_token_expired&.call(e)
155
+ @config.on_token_expired&.call(e)
119
156
  DhanHQ.logger&.warn("[DhanHQ::Client] Auth failure (#{e.class}), retrying once with fresh token")
120
157
  yield
121
158
  end
@@ -218,20 +255,58 @@ module DhanHQ
218
255
  @token_manager.generate!
219
256
  end
220
257
 
258
+ # A client sharing this one's config but rate-limited for a different API tier.
259
+ # RateLimiter.for(api_type) is a shared instance per type, so sibling clients still
260
+ # throttle against the same counters as any other client using that tier.
261
+ #
262
+ # @param api_type [Symbol] Target API tier (:order_api, :data_api, :non_trading_api, :option_chain)
263
+ # @return [DhanHQ::Client]
264
+ def client_for(api_type)
265
+ return self if api_type == @api_type
266
+
267
+ @sibling_clients ||= {}
268
+ @sibling_clients[api_type] ||= self.class.new(config: @config, api_type: api_type)
269
+ end
270
+
221
271
  private
222
272
 
273
+ def resolve_config(config_or_attrs, keyword_config, attrs = {})
274
+ if config_or_attrs.is_a?(DhanHQ::Configuration)
275
+ fork_config_with_attrs(config_or_attrs, attrs)
276
+ elsif config_or_attrs.is_a?(Hash)
277
+ DhanHQ::Configuration.new(config_or_attrs.merge(attrs))
278
+ elsif keyword_config
279
+ fork_config_with_attrs(keyword_config, attrs)
280
+ elsif attrs.any?
281
+ DhanHQ::Configuration.new(attrs)
282
+ else
283
+ DhanHQ.ensure_configuration!
284
+ end
285
+ end
286
+
287
+ # Copies a Configuration when per-instance attributes (e.g. +mode:+) are
288
+ # given alongside it, so an injected or global configuration is never
289
+ # mutated. Without extra attributes the original object is shared unchanged.
290
+ def fork_config_with_attrs(config, attrs)
291
+ return config if attrs.empty?
292
+
293
+ config.dup.tap do |forked|
294
+ attrs.each { |key, value| forked.public_send("#{key}=", value) }
295
+ end
296
+ end
297
+
223
298
  # Simulator that answers state-changing requests locally when dry run is on.
224
299
  #
225
300
  # @return [DhanHQ::DryRun::Simulator]
226
301
  def dry_run
227
- @dry_run ||= DhanHQ::DryRun::Simulator.new
302
+ @dry_run ||= DhanHQ::DryRun::Simulator.new(config: @config)
228
303
  end
229
304
 
230
305
  # A write may be auto-retried only when the user has explicitly opted in.
231
306
  def retryable_write?(method, path)
232
307
  return true unless WritePaths.mutating?(method, path)
233
308
 
234
- DhanHQ.configuration&.retry_non_idempotent_writes? || false
309
+ @config&.retry_non_idempotent_writes? || false
235
310
  end
236
311
 
237
312
  # Fills in a +correlationId+ on order placements that lack one, so a caller who
@@ -240,7 +315,7 @@ module DhanHQ
240
315
  def with_correlation_id(method, path, payload)
241
316
  return payload unless WritePaths.order_placement?(method, path)
242
317
  return payload unless payload.is_a?(Hash)
243
- return payload unless DhanHQ.configuration&.auto_correlation_id?
318
+ return payload unless @config&.auto_correlation_id?
244
319
  return payload if correlation_id?(payload)
245
320
 
246
321
  inject_correlation_id(payload)
@@ -37,13 +37,28 @@ module DhanHQ
37
37
  module OrderAudit
38
38
  private
39
39
 
40
- # Raises an error if LIVE_TRADING is not explicitly enabled.
41
- # Set ENV["LIVE_TRADING"]="true" in production to allow order submission.
40
+ # Raises an error unless live trading is explicitly enabled OR the request
41
+ # targets the sandbox host, where no real money is at risk.
42
+ #
43
+ # Set ENV["LIVE_TRADING"]="true" to allow order submission against the
44
+ # production host. Orders routed to the sandbox host (config.mode =
45
+ # :sandbox, or the trading tiers under :hybrid) skip the gate because they
46
+ # cannot place real orders.
42
47
  def ensure_live_trading!
43
48
  return if ENV["LIVE_TRADING"] == "true"
49
+ return if sandbox_target?
44
50
 
45
51
  raise DhanHQ::LiveTradingDisabledError,
46
- "Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement."
52
+ "Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement, " \
53
+ "or switch to the sandbox with DhanHQ.configure { |c| c.mode = :sandbox } / :hybrid."
54
+ end
55
+
56
+ # True when this resource's requests resolve to the sandbox host.
57
+ # Tolerates hosts that never got a client wired in (bare test doubles).
58
+ def sandbox_target?
59
+ return false unless respond_to?(:client) && client.respond_to?(:environment)
60
+
61
+ client.environment == :sandbox
47
62
  end
48
63
 
49
64
  # Runs the risk pipeline for the given order params.
@@ -120,6 +135,7 @@ module DhanHQ
120
135
  event: event,
121
136
  hostname: inspector.hostname,
122
137
  env: inspector.environment,
138
+ target: request_target,
123
139
  ipv4: inspector.public_ipv4,
124
140
  ipv6: inspector.public_ipv6,
125
141
  security_id: extract_param(params, :securityId, :security_id),
@@ -131,6 +147,13 @@ module DhanHQ
131
147
  DhanHQ.logger&.warn(JSON.generate(entry))
132
148
  end
133
149
 
150
+ # The host the order will be sent to, for the audit trail.
151
+ def request_target
152
+ return "sandbox" if sandbox_target?
153
+
154
+ "live"
155
+ end
156
+
134
157
  # Extracts a value from params trying both camelCase and snake_case keys,
135
158
  # as well as both symbol and string key types.
136
159
  def extract_param(params, camel_key, snake_key)
@@ -15,6 +15,24 @@ module DhanHQ
15
15
  # Default Sandbox API host.
16
16
  # @return [String]
17
17
  SANDBOX_URL = Constants::Urls::SANDBOX_API_BASE
18
+
19
+ # All environments a client can run in.
20
+ #
21
+ # - +:live+ — every request hits the production host.
22
+ # - +:sandbox+ — every request hits the sandbox host (previous `sandbox = true` behaviour).
23
+ # - +:hybrid+ — market data (quotes, option chain, charts, instruments, ScanX)
24
+ # hits production while trading/account requests hit the sandbox host, so a
25
+ # strategy rehearses execution against real prices without risking money.
26
+ # @return [Array<Symbol>]
27
+ MODES = %i[live sandbox hybrid].freeze
28
+
29
+ # API tiers routed to the production host when {#mode} is +:hybrid+.
30
+ # Everything else (orders, positions, funds, statements, ...) routes to the
31
+ # sandbox host. Global Stocks keeps its own tier so it stays on production in
32
+ # hybrid mode — the sandbox host does not mirror the US-equities API.
33
+ # @return [Array<Symbol>]
34
+ HYBRID_LIVE_API_TYPES = %i[data_api quote_api option_chain global_stocks_api].freeze
35
+
18
36
  # The client ID for API authentication.
19
37
  # @return [String, nil] The client ID or `nil` if not set.
20
38
  attr_accessor :client_id
@@ -39,7 +57,55 @@ module DhanHQ
39
57
  attr_writer :base_url
40
58
 
41
59
  # Whether to use the sandbox environment.
42
- attr_accessor :sandbox
60
+ #
61
+ # Backward-compatible facade over {#mode}: setting it to +true+ switches to
62
+ # full sandbox mode, +false+ back to live. Prefer {#mode} for new code — it
63
+ # also supports +:hybrid+ (live market data + sandbox execution).
64
+ #
65
+ # rubocop:disable Naming/PredicateMethod -- legacy attr_accessor-style API,
66
+ # kept as a boolean mirror of +mode+ for every caller written before :hybrid.
67
+ # @return [Boolean] True when every request targets the sandbox host.
68
+ def sandbox
69
+ mode == :sandbox
70
+ end
71
+ # rubocop:enable Naming/PredicateMethod
72
+
73
+ def sandbox=(value)
74
+ self.mode = value ? :sandbox : :live
75
+ end
76
+
77
+ # The environment the client runs in: +:live+, +:sandbox+, or +:hybrid+.
78
+ #
79
+ # Set via +DHAN_MODE+ or in {DhanHQ.configure}. {#sandbox=} remains as an
80
+ # alias for switching between +:live+ and +:sandbox+.
81
+ #
82
+ # @return [Symbol]
83
+ attr_reader :mode
84
+
85
+ # @param value [Symbol, String] One of {MODES} (case-insensitive).
86
+ # @raise [ArgumentError] When the value is not a known mode.
87
+ def mode=(value)
88
+ @mode = coerce_mode!(value)
89
+ end
90
+
91
+ # Per-tier environment overrides applied on top of {#mode}.
92
+ #
93
+ # Keys are API tier symbols (+:order_api+, +:data_api+, +:quote_api+,
94
+ # +:option_chain+, +:non_trading_api+, +:global_stocks_api+); values are
95
+ # +:live+ or +:sandbox+. Useful to, say, keep fund limits on production while
96
+ # everything else runs on the sandbox in hybrid mode:
97
+ #
98
+ # DhanHQ.configure do |c|
99
+ # c.mode = :hybrid
100
+ # c.environment_overrides = { non_trading_api: :live }
101
+ # end
102
+ #
103
+ # @return [Hash{Symbol => Symbol}]
104
+ attr_reader :environment_overrides
105
+
106
+ def environment_overrides=(value)
107
+ @environment_overrides = coerce_environment_overrides(value)
108
+ end
43
109
 
44
110
  # When true, state-changing requests (order placement, modification,
45
111
  # cancellation, position exits, trading controls) are validated and logged but
@@ -176,15 +242,54 @@ module DhanHQ
176
242
  # is nil or the default production URL, returns {SANDBOX_URL}.
177
243
  # @return [String]
178
244
  def base_url
179
- if sandbox? && (@base_url.nil? || @base_url == BASE_URL)
245
+ base_url_for(nil)
246
+ end
247
+
248
+ # Returns the base URL for requests on the given API tier, honouring
249
+ # {#mode}, {#environment_overrides}, and an explicit {#base_url} override.
250
+ #
251
+ # In +:hybrid+ mode data tiers (see {HYBRID_LIVE_API_TYPES}) resolve to the
252
+ # production host while trading and account tiers resolve to the sandbox
253
+ # host, which is what lets a strategy read live prices while placing paper
254
+ # orders.
255
+ #
256
+ # @param api_type [Symbol, nil] API tier the request belongs to. +nil+ uses
257
+ # the mode-level default (+:sandbox+ mode resolves to the sandbox host,
258
+ # +:live+ and +:hybrid+ to production).
259
+ # @return [String]
260
+ def base_url_for(api_type)
261
+ if environment_for(api_type) == :sandbox && (@base_url.nil? || @base_url == BASE_URL)
180
262
  SANDBOX_URL
181
263
  else
182
264
  @base_url || BASE_URL
183
265
  end
184
266
  end
185
267
 
268
+ # The effective environment (+:live+ or +:sandbox+) for requests on the
269
+ # given API tier. Explicit {#environment_overrides} win over the {#mode}
270
+ # defaults.
271
+ #
272
+ # @param api_type [Symbol, nil]
273
+ # @return [Symbol]
274
+ def environment_for(api_type)
275
+ key = api_type&.to_sym
276
+ return @environment_overrides[key] if key && @environment_overrides.key?(key)
277
+
278
+ case mode
279
+ when :sandbox then :sandbox
280
+ when :hybrid then HYBRID_LIVE_API_TYPES.include?(key) ? :live : :sandbox
281
+ else :live
282
+ end
283
+ end
284
+
186
285
  def sandbox?
187
- @sandbox == true
286
+ mode == :sandbox
287
+ end
288
+
289
+ # True when {#mode} is +:hybrid+ — live market data, sandbox execution.
290
+ # @return [Boolean]
291
+ def hybrid?
292
+ mode == :hybrid
188
293
  end
189
294
 
190
295
  # @return [Boolean] True when state-changing requests should be simulated.
@@ -212,34 +317,80 @@ module DhanHQ
212
317
  @ws_debug == true
213
318
  end
214
319
 
215
- # Initializes a new configuration instance with default values.
216
- #
217
- # @example
218
- # config = DhanHQ::Configuration.new
219
- # config.client_id = "your_client_id"
220
- # config.access_token = "your_access_token"
221
- def initialize
222
- @client_id = ENV.fetch("DHAN_CLIENT_ID", nil)
223
- @access_token = ENV.fetch("DHAN_ACCESS_TOKEN", nil)
224
- @sandbox = env_flag("DHAN_SANDBOX", default: false)
225
- @dry_run = env_flag("DHAN_DRY_RUN", default: false)
226
- @retry_non_idempotent_writes = env_flag("DHAN_RETRY_WRITES", default: false)
227
- @auto_correlation_id = env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
228
- @warn_on_ambiguous_write_failure = env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE", default: true)
229
- @base_url = ENV.fetch("DHAN_BASE_URL", nil)
230
- @ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
231
- @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
232
- @ws_market_feed_url = ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
233
- @ws_market_depth_url = ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
234
- @market_depth_level = ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20").to_i
235
- @ws_debug = env_flag("DHAN_WS_DEBUG", default: false)
236
- @ws_user_type = ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
237
- @partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
238
- @partner_secret = ENV.fetch("DHAN_PARTNER_SECRET", nil)
320
+ def initialize(attrs = {})
321
+ @client_id = attrs[:client_id] || ENV.fetch("DHAN_CLIENT_ID", nil)
322
+ @access_token = attrs[:access_token] || ENV.fetch("DHAN_ACCESS_TOKEN", nil)
323
+ @access_token_provider = attrs[:access_token_provider]
324
+ @on_token_expired = attrs[:on_token_expired]
325
+ @sandbox = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
326
+ @dry_run = attrs.key?(:dry_run) ? attrs[:dry_run] : env_flag("DHAN_DRY_RUN", default: false)
327
+ @retry_non_idempotent_writes = attrs.key?(:retry_non_idempotent_writes) ? attrs[:retry_non_idempotent_writes] : env_flag("DHAN_RETRY_WRITES", default: false)
328
+ @auto_correlation_id = attrs.key?(:auto_correlation_id) ? attrs[:auto_correlation_id] : env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
329
+ @warn_on_ambiguous_write_failure = if attrs.key?(:warn_on_ambiguous_write_failure)
330
+ attrs[:warn_on_ambiguous_write_failure]
331
+ else
332
+ env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE",
333
+ default: true)
334
+ end
335
+ @base_url = attrs[:base_url] || ENV.fetch("DHAN_BASE_URL", nil)
336
+ @environment_overrides = coerce_environment_overrides(
337
+ attrs[:environment_overrides] || ENV.fetch("DHAN_ENV_OVERRIDES", nil)
338
+ )
339
+ @mode = resolve_mode(attrs)
340
+ @ws_version = (attrs[:ws_version] || ENV.fetch("DHAN_WS_VERSION", 2)).to_i
341
+ @ws_order_url = attrs[:ws_order_url] || ENV.fetch("DHAN_WS_ORDER_URL", nil)
342
+ @ws_market_feed_url = attrs[:ws_market_feed_url] || ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
343
+ @ws_market_depth_url = attrs[:ws_market_depth_url] || ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
344
+ @market_depth_level = (attrs[:market_depth_level] || ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20")).to_i
345
+ @ws_debug = attrs.key?(:ws_debug) ? attrs[:ws_debug] : env_flag("DHAN_WS_DEBUG", default: false)
346
+ @ws_user_type = attrs[:ws_user_type] || ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
347
+ @partner_id = attrs[:partner_id] || ENV.fetch("DHAN_PARTNER_ID", nil)
348
+ @partner_secret = attrs[:partner_secret] || ENV.fetch("DHAN_PARTNER_SECRET", nil)
349
+ yield(self) if block_given?
239
350
  end
240
351
 
241
352
  private
242
353
 
354
+ # Resolves the initial mode from attributes and ENV.
355
+ #
356
+ # Precedence: an explicit +:mode+ attribute wins, then +DHAN_MODE+, then the
357
+ # legacy +sandbox+ attribute / +DHAN_SANDBOX+ flag, then +:live+.
358
+ def resolve_mode(attrs)
359
+ raw_mode = attrs.key?(:mode) ? attrs[:mode] : ENV.fetch("DHAN_MODE", nil)
360
+ return coerce_mode!(raw_mode) if raw_mode
361
+
362
+ sandbox_flag = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
363
+ sandbox_flag ? :sandbox : :live
364
+ end
365
+
366
+ # Normalises a mode value, raising on anything outside {MODES}.
367
+ def coerce_mode!(value)
368
+ mode = value.to_s.strip.downcase.to_sym
369
+ raise ArgumentError, "unknown mode #{value.inspect}: expected one of #{MODES.join(", ")}" unless MODES.include?(mode)
370
+
371
+ mode
372
+ end
373
+
374
+ # Valid targets for an environment override.
375
+ OVERRIDE_TARGETS = %i[live sandbox].freeze
376
+ private_constant :OVERRIDE_TARGETS
377
+
378
+ # Normalises an environment-overrides hash: symbol keys, :live/:sandbox values.
379
+ def coerce_environment_overrides(value)
380
+ return {} if value.nil? || value == ""
381
+
382
+ value = value.split(",").to_h { |pair| pair.split("=") } if value.is_a?(String)
383
+ raise ArgumentError, "environment_overrides must be a Hash (tier => :live/:sandbox)" unless value.is_a?(Hash)
384
+
385
+ value.each_with_object({}) do |(tier, env), out|
386
+ key = tier.to_s.strip.to_sym
387
+ target = env.to_s.strip.downcase.to_sym
388
+ raise ArgumentError, "environment override for #{key} must be :live or :sandbox (got #{env.inspect})" unless OVERRIDE_TARGETS.include?(target)
389
+
390
+ out[key] = target
391
+ end
392
+ end
393
+
243
394
  # Reads a boolean-ish environment variable, falling back to +default+ when unset.
244
395
  def env_flag(name, default:)
245
396
  raw = ENV.fetch(name, nil)
@@ -130,6 +130,63 @@ module DhanHQ
130
130
  # Backward-compatible alias kept for existing SDK usage.
131
131
  Instrument = InstrumentType
132
132
 
133
+ # ScanX data API enums (POST /v2/data/* — company info, market movers,
134
+ # news headlines, technical indicators).
135
+ module ScanX
136
+ # Exchange segments accepted by POST /v2/data/companyinfo.
137
+ COMPANY_INFO_SEGMENTS = %w[NSE_EQ BSE_EQ].freeze
138
+ # Metric sections returned by POST /v2/data/companyinfo (CO = company
139
+ # overview, RATIOS = valuation/profitability + industry benchmarks,
140
+ # SHP = shareholding pattern).
141
+ COMPANY_INFO_METRICS = %w[CO RATIOS SHP].freeze
142
+ # Instrument kinds accepted by POST /v2/data/companyinfo.
143
+ COMPANY_INFO_INSTRUMENTS = %w[EQUITY].freeze
144
+
145
+ # Exchange segments accepted by POST /v2/data/marketmovers.
146
+ MARKET_MOVERS_SEGMENTS = %w[NSE_FNO BSE_FNO NSE_COMM MCX_COMM NSE_EQ BSE_EQ].freeze
147
+ # Instrument kinds ranked by POST /v2/data/marketmovers.
148
+ MARKET_MOVERS_INSTRUMENTS = %w[OPTIDX OPTSTK OPTFUT FUTIDX FUTSTK FUTCOM EQUITY].freeze
149
+ # Ranking categories for POST /v2/data/marketmovers.
150
+ MARKET_MOVERS_CATEGORIES =
151
+ %w[HIGHEST_OI OI_GAINERS OI_LOSERS TOP_VOLUME PRICE_GAINERS PRICE_LOSERS].freeze
152
+ # Equity universes accepted by POST /v2/data/marketmovers (required when
153
+ # instrument is EQUITY).
154
+ MARKET_MOVERS_UNIVERSES = %w[
155
+ ALL FNO_STOCKS NIFTY_50 NIFTY_BANK FINNIFTY INDIA_VIX NIFTY_MIDCAP NIFTY_NEXT_50
156
+ NIFTY_SMALLCAP_50 NIFTY_MID_CAP_50 NIFTY_100 NIFTY_200 NIFTY_500 NIFTY_MIDCAP_100
157
+ NIFTY_MIDCAP_150 NIFTY_SMALLCAP_100 NIFTY_SMALLCAP_250 NIFTY_MICROCAP_250 NIFTY_AUTO
158
+ NIFTY_PRIVATE_BANK NIFTY_FMCG NIFTY_ENERGY NIFTY_INFRA NIFTY_IT NIFTY_MEDIA NIFTY_METAL
159
+ NIFTY_MNC NIFTY_PHARMA NIFTY_PSU_BANK NIFTY_REALTY NIFTY_SERVICE_SECTOR NIFTY_CUNSUMPTION
160
+ GIFT_NIFTY SENSEX BSE_100 BSE_200 BSE_500 BSE_150_MIDCAP BSE_250_SMALLCAP
161
+ BSE_250_LARGE_MID BSE_400_MID_SMALL BSE_BANKEX BSE_AUTO BSE_CAPITAL_GOODS
162
+ BSE_CONSUMER_DURABLES BSE_ENERGY BSE_FINANCE BSE_FMCG BSE_HEALTHCARE BSE_INDIA_MFG
163
+ BSE_INDUSTRIALS BSE_IPO BSE_IT BSE_METALS BSE_OIL_AND_GAS BSE_POWER BSE_PSU BSE_TELECOM
164
+ ].freeze
165
+
166
+ # News categories accepted by POST /v2/data/newsheadline.
167
+ NEWS_CATEGORIES = %w[
168
+ ALL COMPANIES EQUITY_MARKETS DEBT_MARKETS IPO GLOBAL INDIAN_ECONOMY GLOBAL_ECONOMY
169
+ CURRENCY COMMODITIES CRYPTOCURRENCIES GOVERNMENT_POLICY_AND_REGULATION INFRASTRUCTURE
170
+ INTERNATIONAL_TRADE INVESTMENT_IDEAS REAL_ESTATE STARTUPS TECHNOLOGY MUTUAL_FUNDS
171
+ ].freeze
172
+ # News scoping options (portfolio/watchlist) for POST /v2/data/newsheadline.
173
+ NEWS_UNIVERSES = %w[PORTFOLIO WATCHLIST].freeze
174
+
175
+ # Exchange segments accepted by POST /v2/data/technical.
176
+ TECHNICAL_SEGMENTS = %w[NSE_EQ IDX_I].freeze
177
+ # Instrument kinds accepted by POST /v2/data/technical.
178
+ TECHNICAL_INSTRUMENTS = %w[INDEX EQUITY].freeze
179
+ # Candle timeframes for POST /v2/data/technical (1/5/15 minute, D = daily).
180
+ TECHNICAL_TIMEFRAMES = %w[1 5 15 D].freeze
181
+ # Indicator identifiers computed by POST /v2/data/technical.
182
+ TECHNICAL_INDICATORS = %w[
183
+ SMA_5 SMA_10 SMA_20 SMA_50 SMA_100 SMA_200
184
+ EMA_5 EMA_10 EMA_20 EMA_50 EMA_100 EMA_200
185
+ RSI_14 MACD_HIST STOCH STOCHRSI_14 ATR_14 ADX_14 UO ROC WILLR
186
+ PIVOT_CLASSIC PIVOT_FIBONACCI PIVOT_CAMARILLA
187
+ ].freeze
188
+ end
189
+
133
190
  # Minute intervals allowed by POST /v2/charts/intraday (charts annexure).
134
191
  module ChartInterval
135
192
  ONE = "1"
@@ -536,7 +593,8 @@ module DhanHQ
536
593
  "/v2/marketfeed/",
537
594
  "/v2/optionchain",
538
595
  "/v2/instrument/",
539
- "/v2/charts"
596
+ "/v2/charts",
597
+ "/v2/data"
540
598
  ].freeze
541
599
 
542
600
  # Path prefixes for which the request body (POST/PUT/PATCH) must include dhanClientId.
@@ -555,6 +613,7 @@ module DhanHQ
555
613
  /v2/killswitch
556
614
  /v2/ip
557
615
  /v2/globalstocks
616
+ /v2/data/newsheadline
558
617
  ].freeze
559
618
 
560
619
  # Path prefixes whose non-GET requests change real account state — they place,
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/data/companyinfo (ScanX company fundamentals).
6
+ #
7
+ # securityId (digits), exchangeSegment (NSE_EQ/BSE_EQ), instrument (EQUITY),
8
+ # metrics (one or more of CO / RATIOS / SHP).
9
+ class CompanyInfoContract < BaseContract
10
+ params do
11
+ required(:security_id).filled(:string)
12
+ required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::COMPANY_INFO_SEGMENTS)
13
+ required(:instrument).filled(:string, included_in?: Constants::ScanX::COMPANY_INFO_INSTRUMENTS)
14
+ required(:metrics).filled(:array, min_size?: 1) do
15
+ each(:str?, included_in?: Constants::ScanX::COMPANY_INFO_METRICS)
16
+ end
17
+ end
18
+
19
+ rule(:security_id) do
20
+ key.failure("must be a numeric security id string") unless value.to_s.match?(/\A\d+\z/)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validates request for POST /v2/data/marketmovers (ScanX ranked instruments).
8
+ #
9
+ # exchangeSegment, instrument[] (options / futures / equity kinds, no mixing
10
+ # groups), category, limit (1-100). Expiry is required for options/futures;
11
+ # universe is required for EQUITY and ignored otherwise.
12
+ class MarketMoversContract < BaseContract
13
+ DERIVATIVE_INSTRUMENTS = %w[OPTIDX OPTSTK OPTFUT FUTIDX FUTSTK FUTCOM].freeze
14
+ private_constant :DERIVATIVE_INSTRUMENTS
15
+
16
+ params do
17
+ required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_SEGMENTS)
18
+ required(:instrument).filled(:array, min_size?: 1) do
19
+ each(:str?, included_in?: Constants::ScanX::MARKET_MOVERS_INSTRUMENTS)
20
+ end
21
+ required(:category).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_CATEGORIES)
22
+ required(:limit).filled(:integer, included_in?: 1..100)
23
+ optional(:expiry).filled(:string)
24
+ optional(:universe).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_UNIVERSES)
25
+ end
26
+
27
+ rule(:expiry) do
28
+ next unless value.is_a?(String)
29
+
30
+ unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
31
+ key.failure("must be in YYYY-MM-DD format")
32
+ next
33
+ end
34
+
35
+ Date.parse(value)
36
+ rescue StandardError
37
+ key.failure("must be a valid date")
38
+ end
39
+
40
+ # Expiry is required when ranking derivatives; universe is required for EQUITY.
41
+ rule(:instrument, :expiry) do
42
+ instruments = Array(values[:instrument]).map(&:to_s)
43
+ key(:expiry).failure("is required when instrument includes options or futures") if derivative?(instruments) && values[:expiry].to_s.empty?
44
+ end
45
+
46
+ rule(:instrument, :universe) do
47
+ instruments = Array(values[:instrument]).map(&:to_s)
48
+ key(:universe).failure("is required when instrument is EQUITY") if instruments.include?(Constants::InstrumentType::EQUITY) && values[:universe].to_s.empty?
49
+ end
50
+
51
+ # True when any requested instrument is an option or future.
52
+ def derivative?(instruments)
53
+ instruments.any? { |i| DERIVATIVE_INSTRUMENTS.include?(i) }
54
+ end
55
+ end
56
+ end
57
+ end