DhanHQ 3.3.0 → 4.0.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.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Backtest
5
+ # A single completed long round-trip trade produced by Runner.
6
+ #
7
+ # `fees` is the total cost charged across both legs (entry + exit), already
8
+ # netted into `pnl` — it is not deducted again by callers.
9
+ Trade = Struct.new(
10
+ :entry_time, :entry_price, :exit_time, :exit_price, :quantity, :exit_reason, :fees
11
+ ) do
12
+ # Net profit/loss for this trade, after fees.
13
+ #
14
+ # @return [Float]
15
+ def pnl
16
+ ((exit_price - entry_price) * quantity) - fees.to_f
17
+ end
18
+
19
+ # Net profit/loss as a percentage of the entry value.
20
+ #
21
+ # @return [Float]
22
+ def pnl_pct
23
+ entry_value = entry_price.to_f * quantity.to_f
24
+ return 0.0 if entry_value.zero?
25
+
26
+ (pnl / entry_value) * 100
27
+ end
28
+
29
+ # @return [Boolean] whether this trade closed profitably after fees
30
+ def win?
31
+ pnl.positive?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Backtesting engine that replays a DhanHQ::Strategy::Base against historical
5
+ # OHLC data and reports trades, an equity curve, and summary performance stats.
6
+ #
7
+ # @example Backtest a strategy against daily candles
8
+ # data = DhanHQ::Models::HistoricalData.daily(
9
+ # security_id: "1333", exchange_segment: "NSE_EQ",
10
+ # instrument: "EQUITY", from_date: "2024-01-01", to_date: "2024-12-31"
11
+ # )
12
+ # series = DhanHQ::MarketData::OHLCSeries.from_response(data)
13
+ #
14
+ # result = DhanHQ::Backtest::Runner.new(
15
+ # strategy: MyStrategy.new,
16
+ # data: series,
17
+ # initial_capital: 100_000.0
18
+ # ).run
19
+ #
20
+ # result.summary #=> { total_return_pct: 12.4, num_trades: 8, win_rate: 62.5, ... }
21
+ #
22
+ module Backtest
23
+ end
24
+ end
data/lib/DhanHQ/client.rb CHANGED
@@ -42,42 +42,68 @@ 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 attrs [Hash] Additional keyword configuration attributes
53
+ def initialize(config_or_attrs = nil, config: nil, api_type: :order_api, **attrs)
54
+ @config = resolve_config(config_or_attrs, config, attrs)
55
+ @api_type = api_type
61
56
  @rate_limiter = RateLimiter.for(api_type)
62
57
 
63
58
  raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
64
59
  end
65
60
 
66
61
  # 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.
62
+ # Built on first use and rebuilt whenever the configured base URL changes.
71
63
  #
72
64
  # @return [Faraday::Connection]
73
65
  def connection
74
- current_url = DhanHQ.configuration.base_url
66
+ current_url = @config.base_url
75
67
  return @connection if @connection && @last_base_url == current_url
76
68
 
77
69
  @last_base_url = current_url
78
70
  @connection = build_connection(current_url)
79
71
  end
80
72
 
73
+ # Resource accessors bound to this client instance. Each resource gets the client
74
+ # matching its own API_TYPE, so e.g. option chain calls throttle at 1 per 3 sec
75
+ # instead of borrowing the order client's 10/sec limiter.
76
+ def orders = @orders ||= Resources::Orders.new(client: client_for(Resources::Orders::API_TYPE))
77
+ def market_feed = @market_feed ||= Resources::MarketFeed.new(client: client_for(Resources::MarketFeed::API_TYPE))
78
+ def positions = @positions ||= Resources::Positions.new(client: client_for(Resources::Positions::API_TYPE))
79
+ def holdings = @holdings ||= Resources::Holdings.new(client: client_for(Resources::Holdings::API_TYPE))
80
+ def historical_data = @historical_data ||= Resources::HistoricalData.new(client: client_for(Resources::HistoricalData::API_TYPE))
81
+ def option_chain = @option_chain ||= Resources::OptionChain.new(client: client_for(Resources::OptionChain::API_TYPE))
82
+ def funds = @funds ||= Resources::Funds.new(client: client_for(Resources::Funds::API_TYPE))
83
+ def trades = @trades ||= Resources::Trades.new(client: client_for(Resources::Trades::API_TYPE))
84
+ def forever_orders = @forever_orders ||= Resources::ForeverOrders.new(client: client_for(Resources::ForeverOrders::API_TYPE))
85
+ def alert_orders = @alert_orders ||= Resources::AlertOrders.new(client: client_for(Resources::AlertOrders::API_TYPE))
86
+ def iceberg_orders = @iceberg_orders ||= Resources::IcebergOrders.new(client: client_for(Resources::IcebergOrders::API_TYPE))
87
+ def super_orders = @super_orders ||= Resources::SuperOrders.new(client: client_for(Resources::SuperOrders::API_TYPE))
88
+ def twap_orders = @twap_orders ||= Resources::TwapOrders.new(client: client_for(Resources::TwapOrders::API_TYPE))
89
+ def statements = @statements ||= Resources::Statements.new(client: client_for(Resources::Statements::API_TYPE))
90
+ def kill_switch = @kill_switch ||= Resources::KillSwitch.new(client: client_for(Resources::KillSwitch::API_TYPE))
91
+ def pnl_exit = @pnl_exit ||= Resources::PnlExit.new(client: client_for(Resources::PnlExit::API_TYPE))
92
+ def trader_control = @trader_control ||= Resources::TraderControl.new(client: client_for(Resources::TraderControl::API_TYPE))
93
+ def ip_setup = @ip_setup ||= Resources::IPSetup.new(client: client_for(Resources::IPSetup::API_TYPE))
94
+ def edis = @edis ||= Resources::Edis.new(client: client_for(Resources::Edis::API_TYPE))
95
+
96
+ # WebSocket streams bound to this client instance configuration
97
+ def ws_market_feed(mode: :ticker, url: nil, &block)
98
+ WS::Client.new(mode: mode, url: url, config: @config).tap do |ws|
99
+ ws.start if block_given?
100
+ ws.on(:tick, &block) if block_given?
101
+ end
102
+ end
103
+
104
+ def ws_order_update = WS::Orders::Client.new(config: @config)
105
+ def ws_market_depth = WS::MarketDepth::Client.new(config: @config)
106
+
81
107
  # Sends an HTTP request to the API with automatic retry for transient errors.
82
108
  #
83
109
  # @param method [Symbol] The HTTP method (`:get`, `:post`, `:put`, `:delete`)
@@ -93,8 +119,6 @@ module DhanHQ
93
119
  @token_manager&.ensure_valid_token!
94
120
  @rate_limiter.throttle!
95
121
 
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
122
  effective_retries = retryable_write?(method, path) ? retries : 0
99
123
 
100
124
  with_auth_retry do
@@ -112,10 +136,9 @@ module DhanHQ
112
136
  yield
113
137
  rescue DhanHQ::InvalidAuthenticationError, DhanHQ::InvalidTokenError,
114
138
  DhanHQ::TokenExpiredError, DhanHQ::AuthenticationFailedError => e
115
- config = DhanHQ.configuration
116
- raise unless config&.access_token_provider
139
+ raise unless @config&.access_token_provider
117
140
 
118
- config.on_token_expired&.call(e)
141
+ @config.on_token_expired&.call(e)
119
142
  DhanHQ.logger&.warn("[DhanHQ::Client] Auth failure (#{e.class}), retrying once with fresh token")
120
143
  yield
121
144
  end
@@ -218,20 +241,47 @@ module DhanHQ
218
241
  @token_manager.generate!
219
242
  end
220
243
 
244
+ # A client sharing this one's config but rate-limited for a different API tier.
245
+ # RateLimiter.for(api_type) is a shared instance per type, so sibling clients still
246
+ # throttle against the same counters as any other client using that tier.
247
+ #
248
+ # @param api_type [Symbol] Target API tier (:order_api, :data_api, :non_trading_api, :option_chain)
249
+ # @return [DhanHQ::Client]
250
+ def client_for(api_type)
251
+ return self if api_type == @api_type
252
+
253
+ @sibling_clients ||= {}
254
+ @sibling_clients[api_type] ||= self.class.new(config: @config, api_type: api_type)
255
+ end
256
+
221
257
  private
222
258
 
259
+ def resolve_config(config_or_attrs, keyword_config, attrs = {})
260
+ if config_or_attrs.is_a?(DhanHQ::Configuration)
261
+ config_or_attrs
262
+ elsif config_or_attrs.is_a?(Hash)
263
+ DhanHQ::Configuration.new(config_or_attrs.merge(attrs))
264
+ elsif keyword_config
265
+ keyword_config
266
+ elsif attrs.any?
267
+ DhanHQ::Configuration.new(attrs)
268
+ else
269
+ DhanHQ.ensure_configuration!
270
+ end
271
+ end
272
+
223
273
  # Simulator that answers state-changing requests locally when dry run is on.
224
274
  #
225
275
  # @return [DhanHQ::DryRun::Simulator]
226
276
  def dry_run
227
- @dry_run ||= DhanHQ::DryRun::Simulator.new
277
+ @dry_run ||= DhanHQ::DryRun::Simulator.new(config: @config)
228
278
  end
229
279
 
230
280
  # A write may be auto-retried only when the user has explicitly opted in.
231
281
  def retryable_write?(method, path)
232
282
  return true unless WritePaths.mutating?(method, path)
233
283
 
234
- DhanHQ.configuration&.retry_non_idempotent_writes? || false
284
+ @config&.retry_non_idempotent_writes? || false
235
285
  end
236
286
 
237
287
  # Fills in a +correlationId+ on order placements that lack one, so a caller who
@@ -240,7 +290,7 @@ module DhanHQ
240
290
  def with_correlation_id(method, path, payload)
241
291
  return payload unless WritePaths.order_placement?(method, path)
242
292
  return payload unless payload.is_a?(Hash)
243
- return payload unless DhanHQ.configuration&.auto_correlation_id?
293
+ return payload unless @config&.auto_correlation_id?
244
294
  return payload if correlation_id?(payload)
245
295
 
246
296
  inject_correlation_id(payload)
@@ -132,6 +132,15 @@ module DhanHQ
132
132
  # @return [Integer]
133
133
  attr_accessor :market_depth_level
134
134
 
135
+ # When true, every raw inbound WebSocket frame (market feed, order updates,
136
+ # market depth) is logged as a hex dump at debug level before it's parsed.
137
+ # Off by default -- high volume, and the check happens before any hex
138
+ # encoding work so there's no cost when disabled.
139
+ #
140
+ # Set via +DHAN_WS_DEBUG=true+ or in {DhanHQ.configure}.
141
+ # @return [Boolean]
142
+ attr_accessor :ws_debug
143
+
135
144
  # Setters for websocket URLs
136
145
  attr_writer :ws_order_url, :ws_market_feed_url, :ws_market_depth_url
137
146
 
@@ -198,29 +207,37 @@ module DhanHQ
198
207
  @auto_correlation_id == true
199
208
  end
200
209
 
201
- # Initializes a new configuration instance with default values.
202
- #
203
- # @example
204
- # config = DhanHQ::Configuration.new
205
- # config.client_id = "your_client_id"
206
- # config.access_token = "your_access_token"
207
- def initialize
208
- @client_id = ENV.fetch("DHAN_CLIENT_ID", nil)
209
- @access_token = ENV.fetch("DHAN_ACCESS_TOKEN", nil)
210
- @sandbox = env_flag("DHAN_SANDBOX", default: false)
211
- @dry_run = env_flag("DHAN_DRY_RUN", default: false)
212
- @retry_non_idempotent_writes = env_flag("DHAN_RETRY_WRITES", default: false)
213
- @auto_correlation_id = env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
214
- @warn_on_ambiguous_write_failure = env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE", default: true)
215
- @base_url = ENV.fetch("DHAN_BASE_URL", nil)
216
- @ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
217
- @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
218
- @ws_market_feed_url = ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
219
- @ws_market_depth_url = ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
220
- @market_depth_level = ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20").to_i
221
- @ws_user_type = ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
222
- @partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
223
- @partner_secret = ENV.fetch("DHAN_PARTNER_SECRET", nil)
210
+ # @return [Boolean] True when raw WebSocket frames should be hex-logged.
211
+ def ws_debug?
212
+ @ws_debug == true
213
+ end
214
+
215
+ def initialize(attrs = {})
216
+ @client_id = attrs[:client_id] || ENV.fetch("DHAN_CLIENT_ID", nil)
217
+ @access_token = attrs[:access_token] || ENV.fetch("DHAN_ACCESS_TOKEN", nil)
218
+ @access_token_provider = attrs[:access_token_provider]
219
+ @on_token_expired = attrs[:on_token_expired]
220
+ @sandbox = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
221
+ @dry_run = attrs.key?(:dry_run) ? attrs[:dry_run] : env_flag("DHAN_DRY_RUN", default: false)
222
+ @retry_non_idempotent_writes = attrs.key?(:retry_non_idempotent_writes) ? attrs[:retry_non_idempotent_writes] : env_flag("DHAN_RETRY_WRITES", default: false)
223
+ @auto_correlation_id = attrs.key?(:auto_correlation_id) ? attrs[:auto_correlation_id] : env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
224
+ @warn_on_ambiguous_write_failure = if attrs.key?(:warn_on_ambiguous_write_failure)
225
+ attrs[:warn_on_ambiguous_write_failure]
226
+ else
227
+ env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE",
228
+ default: true)
229
+ end
230
+ @base_url = attrs[:base_url] || ENV.fetch("DHAN_BASE_URL", nil)
231
+ @ws_version = (attrs[:ws_version] || ENV.fetch("DHAN_WS_VERSION", 2)).to_i
232
+ @ws_order_url = attrs[:ws_order_url] || ENV.fetch("DHAN_WS_ORDER_URL", nil)
233
+ @ws_market_feed_url = attrs[:ws_market_feed_url] || ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
234
+ @ws_market_depth_url = attrs[:ws_market_depth_url] || ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
235
+ @market_depth_level = (attrs[:market_depth_level] || ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20")).to_i
236
+ @ws_debug = attrs.key?(:ws_debug) ? attrs[:ws_debug] : env_flag("DHAN_WS_DEBUG", default: false)
237
+ @ws_user_type = attrs[:ws_user_type] || ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
238
+ @partner_id = attrs[:partner_id] || ENV.fetch("DHAN_PARTNER_ID", nil)
239
+ @partner_secret = attrs[:partner_secret] || ENV.fetch("DHAN_PARTNER_SECRET", nil)
240
+ yield(self) if block_given?
224
241
  end
225
242
 
226
243
  private
@@ -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`
@@ -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
@@ -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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Jobs
5
+ # Places a Dhan order via ActiveJob, without ever letting a queue adapter
6
+ # (Sidekiq, Resque, ...) retry it.
7
+ #
8
+ # DhanHQ order writes are not idempotent -- a timed-out POST /v2/orders may
9
+ # already have reached the exchange, so retrying it can place a duplicate
10
+ # order (see the README's "Order Retries and Duplicate Protection"). Most
11
+ # adapters retry unhandled exceptions at the backend level by default
12
+ # (Sidekiq's own retry, independent of ActiveJob's opt-in `retry_on`), so
13
+ # the only adapter-agnostic way to guarantee this write is never silently
14
+ # retried is to make sure the exception never reaches the adapter at all.
15
+ # `discard_on` does exactly that: it's handled inside ActiveJob's own
16
+ # `execute`, so from the adapter's point of view the job completed, not
17
+ # failed -- there is nothing left for the adapter's own retry logic to
18
+ # act on.
19
+ #
20
+ # @example
21
+ # DhanHQ::Jobs::PlaceOrderJob.perform_later(
22
+ # transaction_type: DhanHQ::Constants::TransactionType::BUY,
23
+ # exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
24
+ # product_type: DhanHQ::Constants::ProductType::INTRADAY,
25
+ # order_type: DhanHQ::Constants::OrderType::LIMIT,
26
+ # validity: DhanHQ::Constants::Validity::DAY,
27
+ # security_id: "11536",
28
+ # quantity: 5,
29
+ # price: 1500.0
30
+ # )
31
+ class PlaceOrderJob < ActiveJob::Base
32
+ discard_on DhanHQ::OrderError do |_job, error|
33
+ DhanHQ.logger&.error("[DhanHQ::Jobs::PlaceOrderJob] order rejected: #{error.message}")
34
+ end
35
+
36
+ discard_on DhanHQ::RiskViolation do |_job, error|
37
+ DhanHQ.logger&.warn("[DhanHQ::Jobs::PlaceOrderJob] risk check blocked the order: #{error.message}")
38
+ end
39
+
40
+ # @param params [Hash] Same params accepted by DhanHQ::Models::Order.place.
41
+ # @return [DhanHQ::Models::Order]
42
+ def perform(params)
43
+ DhanHQ::Models::Order.place!(params)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "3.3.0"
5
+ VERSION = "4.0.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
@@ -212,6 +213,7 @@ module DhanHQ
212
213
  # Handle WebSocket message event
213
214
  # @param ev [Event] WebSocket message event
214
215
  def handle_message(ev)
216
+ WS.debug_frame(self.class.name, ev.data)
215
217
  emit(:raw, ev.data)
216
218
  process_message(ev.data) if respond_to?(:process_message, true)
217
219
  end
@@ -20,7 +20,8 @@ module DhanHQ
20
20
  class Client
21
21
  # @param mode [Symbol] Feed mode (:ticker, :quote, :full).
22
22
  # @param url [String, nil] Optional custom WebSocket endpoint.
23
- def initialize(mode: :ticker, url: nil)
23
+ # @param config [DhanHQ::Configuration, nil] Optional configuration instance.
24
+ def initialize(mode: :ticker, url: nil, config: nil)
24
25
  @mode = mode # :ticker, :quote, :full (adjust to your API)
25
26
  @bus = CmdBus.new
26
27
  @state = SubState.new
@@ -29,13 +30,14 @@ module DhanHQ
29
30
  @last_message_at = Concurrent::AtomicReference.new(nil)
30
31
  @connected_at = Concurrent::AtomicReference.new(nil)
31
32
  @reconnects = Concurrent::AtomicFixnum.new(0)
33
+ @config = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
32
34
 
33
- token = DhanHQ.configuration.resolved_access_token
35
+ token = @config.resolved_access_token
34
36
  raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
35
37
 
36
- cid = DhanHQ.configuration.client_id or raise "DhanHQ.client_id not set"
37
- ver = (DhanHQ.configuration.respond_to?(:ws_version) && DhanHQ.configuration.ws_version) || 2
38
- base = url || DhanHQ.configuration.ws_market_feed_url
38
+ cid = @config.client_id or raise "DhanHQ.client_id not set"
39
+ ver = (@config.respond_to?(:ws_version) && @config.ws_version) || 2
40
+ base = url || @config.ws_market_feed_url
39
41
  @url = base.include?("?") ? base : "#{base}?version=#{ver}&token=#{token}&clientId=#{cid}&authType=2"
40
42
  end
41
43
 
@@ -141,6 +141,7 @@ module DhanHQ
141
141
  @ws.on(:open) { |_| handle_open(sessions) }
142
142
 
143
143
  @ws.on :message do |ev|
144
+ WS.debug_frame(self.class.name, ev.data)
144
145
  notify(:message, nil)
145
146
  @on_binary&.call(ev.data) # raw frames to decoder
146
147
  end
@@ -19,11 +19,12 @@ module DhanHQ
19
19
  ##
20
20
  # Initialize Market Depth WebSocket client
21
21
  # @param symbols [Array<String, Hash>] List of symbols (or metadata hashes) to subscribe to
22
+ # @param config [DhanHQ::Configuration, nil] Optional configuration instance
22
23
  # @param options [Hash] Connection options
23
- def initialize(symbols: [], **options)
24
- cfg = DhanHQ.configuration
24
+ def initialize(symbols: [], config: nil, **options)
25
+ cfg = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
25
26
  url = options[:url] || build_market_depth_url(cfg)
26
- super(url: url, **options)
27
+ super(url: url, config: cfg, **options)
27
28
 
28
29
  @symbols = Array(symbols)
29
30
  @subscriptions = Concurrent::Map.new
@@ -16,15 +16,15 @@ module DhanHQ
16
16
  # Maximum age of orders in tracker in seconds (default: 7 days)
17
17
  MAX_ORDER_AGE = ENV.fetch("DHAN_WS_MAX_ORDER_AGE", 604_800).to_i
18
18
 
19
- def initialize(url: nil, **options)
19
+ def initialize(url: nil, config: nil, **options)
20
20
  @callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
21
21
  @started = Concurrent::AtomicBoolean.new(false)
22
22
  @order_tracker = Concurrent::Map.new
23
23
  @order_timestamps = Concurrent::Map.new
24
24
  @cleanup_mutex = Mutex.new
25
25
  @cleanup_thread = nil
26
- cfg = DhanHQ.configuration
27
- @url = url || cfg.ws_order_url
26
+ @config = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
27
+ @url = url || @config.ws_order_url
28
28
  @connection_options = options
29
29
  end
30
30
 
@@ -35,7 +35,7 @@ module DhanHQ
35
35
  return self if @started.true?
36
36
 
37
37
  @started.make_true
38
- @conn = Connection.new(url: @url, **@connection_options)
38
+ @conn = Connection.new(url: @url, config: @config, **@connection_options)
39
39
  @conn.on(:open) { emit(:open, true) }
40
40
  @conn.on(:close) { |payload| emit(:close, payload) }
41
41
  @conn.on(:error) { |error| emit(:error, error) }
@@ -63,6 +63,7 @@ module DhanHQ
63
63
  # Process incoming WebSocket message
64
64
  # @param ev [Event] WebSocket message event
65
65
  def handle_message(ev)
66
+ WS.debug_frame(self.class.name, ev.data)
66
67
  msg = JSON.parse(ev.data, symbolize_names: true)
67
68
  emit(:raw, msg)
68
69
  emit(:message, msg)
@@ -77,7 +78,7 @@ module DhanHQ
77
78
  ##
78
79
  # Authenticate with DhanHQ Orders WebSocket
79
80
  def authenticate
80
- cfg = DhanHQ.configuration
81
+ cfg = @config || DhanHQ.configuration
81
82
 
82
83
  if cfg.ws_user_type.to_s.upcase == "PARTNER"
83
84
  payload = {
data/lib/DhanHQ/ws.rb CHANGED
@@ -34,5 +34,30 @@ module DhanHQ
34
34
  def self.disconnect_all_local!
35
35
  Registry.stop_all
36
36
  end
37
+
38
+ # Cap on how many bytes of a frame get hex-dumped by {debug_frame}. A full
39
+ # depth packet can run several KB; at tick frequency that floods the log
40
+ # long before it adds diagnostic value beyond the first couple hundred
41
+ # bytes (header + the first few fields is normally enough to spot a
42
+ # parsing bug). The full frame size is always logged regardless of the cap.
43
+ DEBUG_FRAME_MAX_BYTES = 256
44
+
45
+ # Logs a raw inbound WebSocket frame as a hex dump when
46
+ # +config.ws_debug+ (+DHAN_WS_DEBUG=true+) is enabled. A no-op otherwise --
47
+ # the flag is checked before any hex encoding work, so there's no cost
48
+ # when debug logging is off. Frames longer than {DEBUG_FRAME_MAX_BYTES}
49
+ # are truncated in the dump, but the logged byte count is always the full
50
+ # frame size.
51
+ #
52
+ # @param source [String] short tag identifying which connection the frame came from
53
+ # @param data [String] raw frame bytes
54
+ # @return [void]
55
+ def self.debug_frame(source, data)
56
+ return unless DhanHQ.configuration&.ws_debug?
57
+
58
+ hex = data.byteslice(0, DEBUG_FRAME_MAX_BYTES).unpack1("H*")
59
+ hex += "...truncated" if data.bytesize > DEBUG_FRAME_MAX_BYTES
60
+ DhanHQ.logger&.debug("[DhanHQ::WS::#{source}] frame (#{data.bytesize} bytes): #{hex}")
61
+ end
37
62
  end
38
63
  end