DhanHQ 2.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 (113) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +26 -0
  4. data/CHANGELOG.md +20 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/GUIDE.md +555 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +463 -0
  9. data/README1.md +521 -0
  10. data/Rakefile +12 -0
  11. data/TAGS +10 -0
  12. data/TODO-1.md +14 -0
  13. data/TODO.md +127 -0
  14. data/app/services/live/order_update_guard_support.rb +75 -0
  15. data/app/services/live/order_update_hub.rb +76 -0
  16. data/app/services/live/order_update_persistence_support.rb +68 -0
  17. data/config/initializers/order_update_hub.rb +16 -0
  18. data/diagram.html +184 -0
  19. data/diagram.md +34 -0
  20. data/docs/rails_integration.md +304 -0
  21. data/exe/DhanHQ +4 -0
  22. data/lib/DhanHQ/client.rb +116 -0
  23. data/lib/DhanHQ/config.rb +32 -0
  24. data/lib/DhanHQ/configuration.rb +72 -0
  25. data/lib/DhanHQ/constants.rb +170 -0
  26. data/lib/DhanHQ/contracts/base_contract.rb +15 -0
  27. data/lib/DhanHQ/contracts/historical_data_contract.rb +28 -0
  28. data/lib/DhanHQ/contracts/margin_calculator_contract.rb +19 -0
  29. data/lib/DhanHQ/contracts/modify_order_contract copy.rb +100 -0
  30. data/lib/DhanHQ/contracts/modify_order_contract.rb +22 -0
  31. data/lib/DhanHQ/contracts/option_chain_contract.rb +31 -0
  32. data/lib/DhanHQ/contracts/order_contract.rb +102 -0
  33. data/lib/DhanHQ/contracts/place_order_contract.rb +119 -0
  34. data/lib/DhanHQ/contracts/position_conversion_contract.rb +24 -0
  35. data/lib/DhanHQ/contracts/slice_order_contract.rb +111 -0
  36. data/lib/DhanHQ/core/base_api.rb +105 -0
  37. data/lib/DhanHQ/core/base_model.rb +266 -0
  38. data/lib/DhanHQ/core/base_resource.rb +50 -0
  39. data/lib/DhanHQ/core/error_handler.rb +19 -0
  40. data/lib/DhanHQ/error_object.rb +49 -0
  41. data/lib/DhanHQ/errors.rb +45 -0
  42. data/lib/DhanHQ/helpers/api_helper.rb +17 -0
  43. data/lib/DhanHQ/helpers/attribute_helper.rb +72 -0
  44. data/lib/DhanHQ/helpers/model_helper.rb +7 -0
  45. data/lib/DhanHQ/helpers/request_helper.rb +69 -0
  46. data/lib/DhanHQ/helpers/response_helper.rb +98 -0
  47. data/lib/DhanHQ/helpers/validation_helper.rb +36 -0
  48. data/lib/DhanHQ/json_loader.rb +23 -0
  49. data/lib/DhanHQ/models/edis.rb +58 -0
  50. data/lib/DhanHQ/models/forever_order.rb +85 -0
  51. data/lib/DhanHQ/models/funds.rb +50 -0
  52. data/lib/DhanHQ/models/historical_data.rb +77 -0
  53. data/lib/DhanHQ/models/holding.rb +56 -0
  54. data/lib/DhanHQ/models/kill_switch.rb +49 -0
  55. data/lib/DhanHQ/models/ledger_entry.rb +60 -0
  56. data/lib/DhanHQ/models/margin.rb +54 -0
  57. data/lib/DhanHQ/models/market_feed.rb +41 -0
  58. data/lib/DhanHQ/models/option_chain.rb +79 -0
  59. data/lib/DhanHQ/models/order.rb +239 -0
  60. data/lib/DhanHQ/models/position.rb +60 -0
  61. data/lib/DhanHQ/models/profile.rb +44 -0
  62. data/lib/DhanHQ/models/super_order.rb +69 -0
  63. data/lib/DhanHQ/models/trade.rb +79 -0
  64. data/lib/DhanHQ/rate_limiter.rb +107 -0
  65. data/lib/DhanHQ/requests/optionchain/nifty.json +5 -0
  66. data/lib/DhanHQ/requests/optionchain/nifty_expiries.json +4 -0
  67. data/lib/DhanHQ/requests/orders/create.json +0 -0
  68. data/lib/DhanHQ/resources/edis.rb +44 -0
  69. data/lib/DhanHQ/resources/forever_orders.rb +53 -0
  70. data/lib/DhanHQ/resources/funds.rb +21 -0
  71. data/lib/DhanHQ/resources/historical_data.rb +34 -0
  72. data/lib/DhanHQ/resources/holdings.rb +21 -0
  73. data/lib/DhanHQ/resources/kill_switch.rb +21 -0
  74. data/lib/DhanHQ/resources/margin_calculator.rb +22 -0
  75. data/lib/DhanHQ/resources/market_feed.rb +56 -0
  76. data/lib/DhanHQ/resources/option_chain.rb +31 -0
  77. data/lib/DhanHQ/resources/orders.rb +70 -0
  78. data/lib/DhanHQ/resources/positions.rb +29 -0
  79. data/lib/DhanHQ/resources/profile.rb +25 -0
  80. data/lib/DhanHQ/resources/statements.rb +42 -0
  81. data/lib/DhanHQ/resources/super_orders.rb +46 -0
  82. data/lib/DhanHQ/resources/trades.rb +23 -0
  83. data/lib/DhanHQ/version.rb +6 -0
  84. data/lib/DhanHQ/ws/client.rb +182 -0
  85. data/lib/DhanHQ/ws/cmd_bus.rb +38 -0
  86. data/lib/DhanHQ/ws/connection.rb +240 -0
  87. data/lib/DhanHQ/ws/decoder.rb +83 -0
  88. data/lib/DhanHQ/ws/errors.rb +0 -0
  89. data/lib/DhanHQ/ws/orders/client.rb +59 -0
  90. data/lib/DhanHQ/ws/orders/connection.rb +148 -0
  91. data/lib/DhanHQ/ws/orders.rb +13 -0
  92. data/lib/DhanHQ/ws/packets/depth_delta_packet.rb +20 -0
  93. data/lib/DhanHQ/ws/packets/disconnect_packet.rb +15 -0
  94. data/lib/DhanHQ/ws/packets/full_packet.rb +40 -0
  95. data/lib/DhanHQ/ws/packets/header.rb +23 -0
  96. data/lib/DhanHQ/ws/packets/index_packet.rb +14 -0
  97. data/lib/DhanHQ/ws/packets/market_depth_level.rb +21 -0
  98. data/lib/DhanHQ/ws/packets/market_status_packet.rb +14 -0
  99. data/lib/DhanHQ/ws/packets/oi_packet.rb +15 -0
  100. data/lib/DhanHQ/ws/packets/prev_close_packet.rb +16 -0
  101. data/lib/DhanHQ/ws/packets/quote_packet.rb +26 -0
  102. data/lib/DhanHQ/ws/packets/ticker_packet.rb +16 -0
  103. data/lib/DhanHQ/ws/registry.rb +46 -0
  104. data/lib/DhanHQ/ws/segments.rb +75 -0
  105. data/lib/DhanHQ/ws/singleton_lock.rb +54 -0
  106. data/lib/DhanHQ/ws/sub_state.rb +59 -0
  107. data/lib/DhanHQ/ws/websocket_packet_parser.rb +165 -0
  108. data/lib/DhanHQ/ws.rb +37 -0
  109. data/lib/DhanHQ.rb +135 -0
  110. data/lib/ta/technical_analysis.rb +405 -0
  111. data/sig/DhanHQ.rbs +4 -0
  112. data/watchlist.csv +3 -0
  113. metadata +283 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ # Resource client for multi-leg super orders.
6
+ class SuperOrders < BaseAPI
7
+ # Super orders are executed via the trading API.
8
+ API_TYPE = :order_api
9
+ # Base path for super order endpoints.
10
+ HTTP_PATH = "/v2/super/orders"
11
+
12
+ # Lists all configured super orders.
13
+ #
14
+ # @return [Array<Hash>]
15
+ def all
16
+ get("")
17
+ end
18
+
19
+ # Creates a new super order.
20
+ #
21
+ # @param params [Hash]
22
+ # @return [Hash]
23
+ def create(params)
24
+ post("", params: params)
25
+ end
26
+
27
+ # Updates an existing super order.
28
+ #
29
+ # @param order_id [String]
30
+ # @param params [Hash]
31
+ # @return [Hash]
32
+ def update(order_id, params)
33
+ put("/#{order_id}", params: params)
34
+ end
35
+
36
+ # Cancels a specific leg from a super order.
37
+ #
38
+ # @param order_id [String]
39
+ # @param leg_name [String]
40
+ # @return [Hash]
41
+ def cancel(order_id, leg_name)
42
+ delete("/#{order_id}/#{leg_name}")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ # Provides access to current day trades endpoints
6
+ class Trades < BaseAPI
7
+ # Trade history is fetched from the trading API tier.
8
+ API_TYPE = :order_api
9
+ # Base path for trade retrieval.
10
+ HTTP_PATH = "/v2/trades"
11
+
12
+ # GET /v2/trades
13
+ def all
14
+ get("")
15
+ end
16
+
17
+ # GET /v2/trades/{order-id}
18
+ def find(order_id)
19
+ get("/#{order_id}")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Semantic version of the DhanHQ client gem.
5
+ VERSION = "2.1.0"
6
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require_relative "cmd_bus"
5
+ require_relative "sub_state"
6
+ require_relative "connection"
7
+ require_relative "decoder"
8
+ require_relative "segments"
9
+ require_relative "registry"
10
+
11
+ module DhanHQ
12
+ module WS
13
+ # Client responsible for managing the lifecycle of a streaming connection
14
+ # to the DhanHQ market data WebSocket.
15
+ #
16
+ # The client encapsulates reconnection logic, subscription state tracking,
17
+ # and event dispatching. It is typically used indirectly via
18
+ # {DhanHQ::WS.connect}, but can also be instantiated directly for more
19
+ # advanced flows.
20
+ class Client
21
+ # @param mode [Symbol] Feed mode (:ticker, :quote, :full).
22
+ # @param url [String, nil] Optional custom WebSocket endpoint.
23
+ def initialize(mode: :ticker, url: nil)
24
+ @mode = mode # :ticker, :quote, :full (adjust to your API)
25
+ @bus = CmdBus.new
26
+ @state = SubState.new
27
+ @callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
28
+ @started = Concurrent::AtomicBoolean.new(false)
29
+
30
+ token = DhanHQ.configuration.access_token or raise "DhanHQ.access_token not set"
31
+ cid = DhanHQ.configuration.client_id or raise "DhanHQ.client_id not set"
32
+ ver = (DhanHQ.configuration.respond_to?(:ws_version) && DhanHQ.configuration.ws_version) || 2
33
+ @url = url || "wss://api-feed.dhan.co?version=#{ver}&token=#{token}&clientId=#{cid}&authType=2"
34
+ end
35
+
36
+ # Starts the WebSocket connection and event loop.
37
+ #
38
+ # @return [DhanHQ::WS::Client] self, to allow method chaining.
39
+ def start
40
+ return self if @started.true?
41
+
42
+ @started.make_true
43
+ @conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state) do |binary|
44
+ tick = Decoder.decode(binary)
45
+ emit(:tick, tick) if tick
46
+ end
47
+ Registry.register(self)
48
+ install_at_exit_once!
49
+ @conn.start
50
+ self
51
+ end
52
+
53
+ # Gracefully stops the connection without sending a manual disconnect
54
+ # frame.
55
+ #
56
+ # @return [DhanHQ::WS::Client] self.
57
+ def stop
58
+ return unless @started.true?
59
+
60
+ @started.make_false
61
+ @conn&.stop
62
+ Registry.unregister(self)
63
+ emit(:close, true)
64
+ self
65
+ end
66
+
67
+ # Immediately disconnects from the feed by sending the disconnect frame
68
+ # (RequestCode 12) before closing the socket.
69
+ #
70
+ # @return [DhanHQ::WS::Client] self.
71
+ def disconnect!
72
+ return self unless @started.true?
73
+
74
+ @started.make_false
75
+ @conn&.disconnect!
76
+ Registry.unregister(self)
77
+ emit(:close, true)
78
+ self
79
+ end
80
+
81
+ # Indicates whether the underlying WebSocket connection is open.
82
+ #
83
+ # @return [Boolean]
84
+ def connected?
85
+ return false unless @started.true?
86
+
87
+ @conn&.open? || false
88
+ end
89
+
90
+ # Subscribes to updates for a single instrument.
91
+ #
92
+ # @param segment [String, Symbol, Integer]
93
+ # @param security_id [String, Integer]
94
+ # @return [DhanHQ::WS::Client] self.
95
+ def subscribe_one(segment:, security_id:)
96
+ norm = Segments.normalize_instrument(ExchangeSegment: segment, SecurityId: security_id)
97
+ DhanHQ.logger&.info("[DhanHQ::WS] subscribe_one (normalized) -> #{norm}")
98
+ @bus.sub([prune(norm)])
99
+ self
100
+ end
101
+
102
+ # Subscribes to updates for a list of instruments.
103
+ #
104
+ # @param list [Array<Hash>] Array containing instrument hashes with
105
+ # +:ExchangeSegment+ and +:SecurityId+ keys.
106
+ # @return [DhanHQ::WS::Client] self.
107
+ def subscribe_many(list)
108
+ norms = Segments.normalize_instruments(list).map { |i| prune(i) }
109
+ DhanHQ.logger&.info("[DhanHQ::WS] subscribe_many (normalized) -> #{norms}")
110
+ @bus.sub(norms)
111
+ self
112
+ end
113
+
114
+ # Removes the subscription for a single instrument.
115
+ #
116
+ # @param segment [String, Symbol, Integer]
117
+ # @param security_id [String, Integer]
118
+ # @return [DhanHQ::WS::Client] self.
119
+ def unsubscribe_one(segment:, security_id:)
120
+ norm = Segments.normalize_instrument(ExchangeSegment: segment, SecurityId: security_id)
121
+ DhanHQ.logger&.info("[DhanHQ::WS] unsubscribe_one (normalized) -> #{norm}")
122
+ @bus.unsub([prune(norm)])
123
+ self
124
+ end
125
+
126
+ # Removes the subscriptions for a list of instruments.
127
+ #
128
+ # @param list [Array<Hash>] Instrument definitions to unsubscribe.
129
+ # @return [DhanHQ::WS::Client] self.
130
+ def unsubscribe_many(list)
131
+ norms = Segments.normalize_instruments(list).map { |i| prune(i) }
132
+ DhanHQ.logger&.info("[DhanHQ::WS] unsubscribe_many (normalized) -> #{norms}")
133
+ @bus.unsub(norms)
134
+ self
135
+ end
136
+
137
+ # Installs a single +at_exit+ hook to close open WebSocket clients.
138
+ #
139
+ # @return [void]
140
+ def self.install_at_exit_hook!
141
+ return if defined?(@_at_exit_installed) && @_at_exit_installed
142
+
143
+ @_at_exit_installed = true
144
+ at_exit do
145
+ DhanHQ.logger&.info("[DhanHQ::WS] at_exit: disconnecting all local clients")
146
+ Registry.stop_all
147
+ rescue StandardError => e
148
+ DhanHQ.logger&.debug("[DhanHQ::WS] at_exit error #{e.class}: #{e.message}")
149
+ end
150
+ end
151
+
152
+ # Registers a callback for a given event.
153
+ #
154
+ # @param event [Symbol] Event name (:tick, :open, :close, :error).
155
+ # @yieldparam payload [Object] Event payload.
156
+ # @return [DhanHQ::WS::Client] self.
157
+ def on(event, &blk)
158
+ @callbacks[event] << blk
159
+ self
160
+ end
161
+
162
+ private
163
+
164
+ def prune(h) = { ExchangeSegment: h[:ExchangeSegment], SecurityId: h[:SecurityId] }
165
+
166
+ def emit(event, payload)
167
+ begin
168
+ @callbacks[event].dup
169
+ rescue StandardError
170
+ []
171
+ end.each { |cb| cb.call(payload) }
172
+ end
173
+
174
+ def install_at_exit_once!
175
+ return if defined?(@at_exit_installed) && @at_exit_installed
176
+
177
+ @at_exit_installed = true
178
+ at_exit { Registry.stop_all }
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module WS
5
+ # Thread-safe queue that buffers subscription commands until the
6
+ # connection is ready to send them.
7
+ class CmdBus
8
+ # Represents a subscription command queued for execution.
9
+ Command = Struct.new(:op, :payload, keyword_init: true)
10
+
11
+ def initialize
12
+ @q = Queue.new
13
+ end
14
+
15
+ # Queues a subscribe command.
16
+ #
17
+ # @param list [Array<Hash>] Instruments to subscribe.
18
+ # @return [Command]
19
+ def sub(list) = @q.push(Command.new(op: :sub, payload: list))
20
+
21
+ # Queues an unsubscribe command.
22
+ #
23
+ # @param list [Array<Hash>] Instruments to unsubscribe.
24
+ # @return [Command]
25
+ def unsub(list) = @q.push(Command.new(op: :unsub, payload: list))
26
+
27
+ # Drains all queued commands without blocking.
28
+ #
29
+ # @return [Array<Command>]
30
+ def drain
31
+ out = []
32
+ loop { out << @q.pop(true) }
33
+ rescue ThreadError
34
+ out
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "eventmachine"
4
+ require "faye/websocket"
5
+ require "json"
6
+
7
+ module DhanHQ
8
+ module WS
9
+ # Low-level wrapper responsible for establishing and maintaining the raw
10
+ # WebSocket connection to the streaming API.
11
+ class Connection
12
+ SUB_CODES = { ticker: 15, quote: 17, full: 21 }.freeze # adjust if needed
13
+ # Request codes used when unsubscribing from feeds.
14
+ UNSUB_CODES = { ticker: 16, quote: 18, full: 22 }.freeze
15
+
16
+ COOL_OFF_429 = 60 # seconds to cool off on 429
17
+ MAX_BACKOFF = 90 # cap exponential backoff
18
+
19
+ attr_reader :stopping
20
+
21
+ # @param url [String] WebSocket endpoint URL.
22
+ # @param mode [Symbol] Feed mode (:ticker, :quote, :full).
23
+ # @param bus [DhanHQ::WS::CmdBus] Command queue feeding subscription
24
+ # changes.
25
+ # @param state [DhanHQ::WS::SubState] Tracks subscription status.
26
+ # @yield [binary]
27
+ # @yieldparam binary [String] Raw binary frame received from the socket.
28
+ def initialize(url:, mode:, bus:, state:, &on_binary)
29
+ @url = url
30
+ @mode = mode
31
+ @bus = bus
32
+ @state = state
33
+ @on_binary = on_binary
34
+ @stop = false
35
+ @stopping = false
36
+ @ws = nil
37
+ @timer = nil
38
+ @cooloff_until = nil
39
+ @thr = nil
40
+ end
41
+
42
+ # Starts the connection in a background thread.
43
+ #
44
+ # @return [DhanHQ::WS::Connection] self.
45
+ def start
46
+ return self if @thr&.alive?
47
+
48
+ @thr = Thread.new { loop_run }
49
+ self
50
+ end
51
+
52
+ # Stops the connection without sending the explicit disconnect frame.
53
+ #
54
+ # @return [DhanHQ::WS::Connection] self.
55
+ def stop
56
+ @stop = true
57
+ @stopping = true
58
+ if @ws
59
+ begin
60
+ @ws.close
61
+ rescue StandardError
62
+ end
63
+ end
64
+ self
65
+ end
66
+
67
+ # Sends the disconnect frame (RequestCode 12) and closes the socket.
68
+ #
69
+ # @return [DhanHQ::WS::Connection] self.
70
+ def disconnect!
71
+ @stop = true
72
+ @stopping = true
73
+ begin
74
+ send_disconnect
75
+ rescue StandardError
76
+ ensure
77
+ @ws&.close
78
+ end
79
+ self
80
+ end
81
+
82
+ # Indicates whether the underlying socket is currently open.
83
+ #
84
+ # @return [Boolean]
85
+ def open?
86
+ @ws && @ws.instance_variable_get(:@driver)&.ready_state == 1
87
+ rescue StandardError
88
+ false
89
+ end
90
+
91
+ private
92
+
93
+ def loop_run
94
+ backoff = 2.0
95
+ until @stop
96
+ failed = false
97
+ got_429 = false # rubocop:disable Naming/VariableNumber
98
+
99
+ # respect any active cool-off window
100
+ sleep (@cooloff_until - Time.now).ceil if @cooloff_until && Time.now < @cooloff_until
101
+
102
+ begin
103
+ EM.run do
104
+ @ws = Faye::WebSocket::Client.new(@url, nil, headers: default_headers)
105
+
106
+ @ws.on :open do |_|
107
+ DhanHQ.logger&.info("[DhanHQ::WS] open")
108
+ # re-subscribe snapshot on reconnect
109
+ snapshot = @state.snapshot.map do |k|
110
+ seg, sid = k.split(":")
111
+ { ExchangeSegment: seg, SecurityId: sid }
112
+ end
113
+ send_sub(snapshot) unless snapshot.empty?
114
+ @timer = EM.add_periodic_timer(0.25) { drain_and_send }
115
+ end
116
+
117
+ @ws.on :message do |ev|
118
+ @on_binary&.call(ev.data) # raw frames to decoder
119
+ end
120
+
121
+ @ws.on :close do |ev|
122
+ # If we initiated stop/disconnect, DO NOT reconnect regardless of code.
123
+ EM.cancel_timer(@timer) if @timer
124
+ @timer = nil
125
+ msg = "[DhanHQ::WS] close #{ev.code} #{ev.reason}"
126
+ DhanHQ.logger&.warn(msg)
127
+
128
+ if @stopping
129
+ failed = false
130
+ else
131
+ failed = (ev.code != 1000)
132
+ got_429 = ev.reason.to_s.include?("429")
133
+ end
134
+ EM.stop
135
+ end
136
+
137
+ @ws.on :error do |ev|
138
+ DhanHQ.logger&.error("[DhanHQ::WS] error #{ev.message}")
139
+ failed = true
140
+ end
141
+ end
142
+ rescue StandardError => e
143
+ DhanHQ.logger&.error("[DhanHQ::WS] crashed #{e.class} #{e.message}")
144
+ failed = true
145
+ ensure
146
+ break if @stop
147
+
148
+ if got_429
149
+ @cooloff_until = Time.now + COOL_OFF_429
150
+ DhanHQ.logger&.warn("[DhanHQ::WS] cooling off #{COOL_OFF_429}s due to 429")
151
+ end
152
+
153
+ if failed
154
+ # exponential backoff with jitter
155
+ sleep_time = [backoff, MAX_BACKOFF].min
156
+ jitter = rand(0.2 * sleep_time)
157
+ DhanHQ.logger&.warn("[DhanHQ::WS] reconnecting in #{(sleep_time + jitter).round(1)}s")
158
+ sleep(sleep_time + jitter)
159
+ backoff *= 2.0
160
+ else
161
+ backoff = 2.0 # reset only after a clean session end
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ def default_headers
168
+ { "User-Agent" => "dhanhq-ruby/#{defined?(DhanHQ::VERSION) ? DhanHQ::VERSION : "dev"} Ruby/#{RUBY_VERSION}" }
169
+ end
170
+
171
+ def drain_and_send
172
+ cmds = @bus.drain
173
+ return if cmds.empty?
174
+
175
+ subs, unsubs = cmds.partition { |c| c.op == :sub }
176
+
177
+ unless subs.empty?
178
+ list = uniq(flatten(subs.map(&:payload)))
179
+ new_only = @state.want_sub(list)
180
+ unless new_only.empty?
181
+ send_sub(new_only)
182
+ @state.mark_subscribed!(new_only)
183
+ end
184
+ end
185
+
186
+ return if unsubs.empty?
187
+
188
+ list = uniq(flatten(unsubs.map(&:payload)))
189
+ exist_only = @state.want_unsub(list)
190
+ return if exist_only.empty?
191
+
192
+ send_unsub(exist_only)
193
+ @state.mark_unsubscribed!(exist_only)
194
+ end
195
+
196
+ def send_sub(list)
197
+ return if list.empty?
198
+
199
+ list.each_slice(100) do |chunk|
200
+ payload = { RequestCode: SUB_CODES.fetch(@mode), InstrumentCount: chunk.size, InstrumentList: chunk }
201
+ DhanHQ.logger&.info("[DhanHQ::WS] SUB -> +#{chunk.size} (total=#{list.size})")
202
+ @ws.send(payload.to_json)
203
+ end
204
+ end
205
+
206
+ def send_unsub(list)
207
+ return if list.empty?
208
+
209
+ list.each_slice(100) do |chunk|
210
+ payload = { RequestCode: UNSUB_CODES.fetch(@mode), InstrumentCount: chunk.size, InstrumentList: chunk }
211
+ DhanHQ.logger&.info("[DhanHQ::WS] UNSUB -> -#{chunk.size} (total=#{list.size})")
212
+ @ws.send(payload.to_json)
213
+ end
214
+ end
215
+
216
+ def send_disconnect
217
+ return unless @ws
218
+
219
+ payload = { RequestCode: 12 } # per Dhan: Disconnect Feed
220
+ DhanHQ.logger&.info("[DhanHQ::WS] DISCONNECT -> #{payload}")
221
+ @ws.send(payload.to_json)
222
+ rescue StandardError => e
223
+ DhanHQ.logger&.debug("[DhanHQ::WS] send_disconnect error #{e.class}: #{e.message}")
224
+ end
225
+
226
+ def flatten(a) = a.flatten
227
+
228
+ def uniq(list)
229
+ seen = {}
230
+ list.each_with_object([]) do |i, out|
231
+ k = "#{i[:ExchangeSegment]}:#{i[:SecurityId]}"
232
+ next if seen[k]
233
+
234
+ out << i
235
+ seen[k] = true
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "websocket_packet_parser"
4
+ require_relative "segments"
5
+
6
+ module DhanHQ
7
+ module WS
8
+ # Translates the binary WebSocket frames into Ruby hashes that can be
9
+ # consumed by client code.
10
+ class Decoder
11
+ # Mapping of feed response codes to semantic event kinds.
12
+ FEED_KIND = {
13
+ 2 => :ticker, 4 => :quote, 5 => :oi, 6 => :prev_close, 8 => :full, 50 => :disconnect, 41 => :depth_bid, 51 => :depth_ask
14
+ }.freeze
15
+
16
+ # Parses a binary packet and returns a normalized hash representation.
17
+ #
18
+ # @param binary [String] Raw WebSocket frame payload.
19
+ # @return [Hash, nil] Normalized tick data or nil when the packet should
20
+ # be ignored.
21
+ def self.decode(binary)
22
+ pkt = WebsocketPacketParser.new(binary).parse
23
+ return nil if pkt.nil? || pkt.empty?
24
+
25
+ kind = FEED_KIND[pkt[:feed_response_code]] || :unknown
26
+ segstr = Segments.from_code(pkt[:exchange_segment])
27
+ sid = pkt[:security_id].to_s
28
+
29
+ # pp pkt
30
+ case kind
31
+ when :ticker
32
+ {
33
+ kind: :ticker, segment: segstr, security_id: sid,
34
+ ltp: pkt[:ltp].to_f, ts: pkt[:ltt].to_i
35
+ }
36
+ when :quote
37
+ {
38
+ kind: :quote, segment: segstr, security_id: sid,
39
+ ltp: pkt[:ltp].to_f, ts: pkt[:ltt].to_i, atp: pkt[:atp].to_f,
40
+ vol: pkt[:volume].to_i, ts_buy_qty: pkt[:total_buy_qty].to_i, ts_sell_qty: pkt[:total_sell_qty].to_i,
41
+ day_open: pkt[:day_open]&.to_f, day_high: pkt[:day_high]&.to_f, day_low: pkt[:day_low]&.to_f, day_close: pkt[:day_close]&.to_f
42
+ }
43
+ when :full
44
+ out = {
45
+ kind: :full, segment: segstr, security_id: sid,
46
+ ltp: pkt[:ltp].to_f, ts: pkt[:ltt].to_i, atp: pkt[:atp].to_f,
47
+ vol: pkt[:volume].to_i, ts_buy_qty: pkt[:total_buy_qty].to_i, ts_sell_qty: pkt[:total_sell_qty].to_i,
48
+ oi: pkt[:open_interest]&.to_i, oi_high: pkt[:highest_open_interest]&.to_i, oi_low: pkt[:lowest_open_interest]&.to_i,
49
+ day_open: pkt[:day_open]&.to_f, day_high: pkt[:day_high]&.to_f, day_low: pkt[:day_low]&.to_f, day_close: pkt[:day_close]&.to_f
50
+ }
51
+ # First depth level (if present)
52
+ if (md = pkt[:market_depth]).respond_to?(:[]) && md[0]
53
+ lvl = md[0]
54
+ out[:bid] = lvl.respond_to?(:bid_price) ? lvl.bid_price.to_f : nil
55
+ out[:ask] = lvl.respond_to?(:ask_price) ? lvl.ask_price.to_f : nil
56
+ end
57
+ out
58
+ when :oi
59
+ { kind: :oi, segment: segstr, security_id: sid, oi: pkt[:open_interest].to_i }
60
+ when :prev_close
61
+ { kind: :prev_close, segment: segstr, security_id: sid, prev_close: pkt[:prev_close].to_f,
62
+ oi_prev: pkt[:oi_prev].to_i }
63
+ when :depth_bid, :depth_ask
64
+ {
65
+ kind: kind, segment: segstr, security_id: sid,
66
+ bid_quantity: pkt[:bid_quantity], ask_quantity: pkt[:ask_quantity],
67
+ no_of_bid_orders: pkt[:no_of_bid_orders], no_of_ask_orders: pkt[:no_of_ask_orders],
68
+ bid: pkt[:bid_price], ask: pkt[:ask_price]
69
+ }
70
+ when :disconnect
71
+ DhanHQ.logger&.warn("[DhanHQ::WS] disconnect code=#{pkt[:disconnection_code]} seg=#{segstr} sid=#{sid}")
72
+ nil
73
+ else
74
+ DhanHQ.logger&.debug("[DhanHQ::WS] unknown feed kind code=#{pkt[:feed_response_code]}")
75
+ nil
76
+ end
77
+ rescue StandardError => e
78
+ DhanHQ.logger&.debug("[DhanHQ::WS::Decoder] #{e.class}: #{e.message}")
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
File without changes
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require_relative "connection"
5
+
6
+ module DhanHQ
7
+ module WS
8
+ module Orders
9
+ class Client
10
+ def initialize(url: nil)
11
+ @callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
12
+ @started = Concurrent::AtomicBoolean.new(false)
13
+ cfg = DhanHQ.configuration
14
+ @url = url || cfg.ws_order_url
15
+ end
16
+
17
+ def start
18
+ return self if @started.true?
19
+ @started.make_true
20
+ @conn = Connection.new(url: @url) do |msg|
21
+ emit(:update, msg) if msg&.dig(:Type) == "order_alert"
22
+ emit(:raw, msg)
23
+ end
24
+ @conn.start
25
+ DhanHQ::WS::Registry.register(self) if defined?(DhanHQ::WS::Registry)
26
+ self
27
+ end
28
+
29
+ def stop
30
+ return unless @started.true?
31
+ @started.make_false
32
+ @conn&.stop
33
+ emit(:close, true)
34
+ DhanHQ::WS::Registry.unregister(self) if defined?(DhanHQ::WS::Registry)
35
+ end
36
+
37
+ def disconnect!
38
+ @conn&.disconnect!
39
+ end
40
+
41
+ def on(event, &blk)
42
+ @callbacks[event] << blk
43
+ self
44
+ end
45
+
46
+ private
47
+
48
+ def emit(event, payload)
49
+ list = begin
50
+ @callbacks[event]
51
+ rescue StandardError
52
+ []
53
+ end
54
+ list.each { |cb| cb.call(payload) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end