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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +26 -0
- data/CHANGELOG.md +20 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/GUIDE.md +555 -0
- data/LICENSE.txt +21 -0
- data/README.md +463 -0
- data/README1.md +521 -0
- data/Rakefile +12 -0
- data/TAGS +10 -0
- data/TODO-1.md +14 -0
- data/TODO.md +127 -0
- data/app/services/live/order_update_guard_support.rb +75 -0
- data/app/services/live/order_update_hub.rb +76 -0
- data/app/services/live/order_update_persistence_support.rb +68 -0
- data/config/initializers/order_update_hub.rb +16 -0
- data/diagram.html +184 -0
- data/diagram.md +34 -0
- data/docs/rails_integration.md +304 -0
- data/exe/DhanHQ +4 -0
- data/lib/DhanHQ/client.rb +116 -0
- data/lib/DhanHQ/config.rb +32 -0
- data/lib/DhanHQ/configuration.rb +72 -0
- data/lib/DhanHQ/constants.rb +170 -0
- data/lib/DhanHQ/contracts/base_contract.rb +15 -0
- data/lib/DhanHQ/contracts/historical_data_contract.rb +28 -0
- data/lib/DhanHQ/contracts/margin_calculator_contract.rb +19 -0
- data/lib/DhanHQ/contracts/modify_order_contract copy.rb +100 -0
- data/lib/DhanHQ/contracts/modify_order_contract.rb +22 -0
- data/lib/DhanHQ/contracts/option_chain_contract.rb +31 -0
- data/lib/DhanHQ/contracts/order_contract.rb +102 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +119 -0
- data/lib/DhanHQ/contracts/position_conversion_contract.rb +24 -0
- data/lib/DhanHQ/contracts/slice_order_contract.rb +111 -0
- data/lib/DhanHQ/core/base_api.rb +105 -0
- data/lib/DhanHQ/core/base_model.rb +266 -0
- data/lib/DhanHQ/core/base_resource.rb +50 -0
- data/lib/DhanHQ/core/error_handler.rb +19 -0
- data/lib/DhanHQ/error_object.rb +49 -0
- data/lib/DhanHQ/errors.rb +45 -0
- data/lib/DhanHQ/helpers/api_helper.rb +17 -0
- data/lib/DhanHQ/helpers/attribute_helper.rb +72 -0
- data/lib/DhanHQ/helpers/model_helper.rb +7 -0
- data/lib/DhanHQ/helpers/request_helper.rb +69 -0
- data/lib/DhanHQ/helpers/response_helper.rb +98 -0
- data/lib/DhanHQ/helpers/validation_helper.rb +36 -0
- data/lib/DhanHQ/json_loader.rb +23 -0
- data/lib/DhanHQ/models/edis.rb +58 -0
- data/lib/DhanHQ/models/forever_order.rb +85 -0
- data/lib/DhanHQ/models/funds.rb +50 -0
- data/lib/DhanHQ/models/historical_data.rb +77 -0
- data/lib/DhanHQ/models/holding.rb +56 -0
- data/lib/DhanHQ/models/kill_switch.rb +49 -0
- data/lib/DhanHQ/models/ledger_entry.rb +60 -0
- data/lib/DhanHQ/models/margin.rb +54 -0
- data/lib/DhanHQ/models/market_feed.rb +41 -0
- data/lib/DhanHQ/models/option_chain.rb +79 -0
- data/lib/DhanHQ/models/order.rb +239 -0
- data/lib/DhanHQ/models/position.rb +60 -0
- data/lib/DhanHQ/models/profile.rb +44 -0
- data/lib/DhanHQ/models/super_order.rb +69 -0
- data/lib/DhanHQ/models/trade.rb +79 -0
- data/lib/DhanHQ/rate_limiter.rb +107 -0
- data/lib/DhanHQ/requests/optionchain/nifty.json +5 -0
- data/lib/DhanHQ/requests/optionchain/nifty_expiries.json +4 -0
- data/lib/DhanHQ/requests/orders/create.json +0 -0
- data/lib/DhanHQ/resources/edis.rb +44 -0
- data/lib/DhanHQ/resources/forever_orders.rb +53 -0
- data/lib/DhanHQ/resources/funds.rb +21 -0
- data/lib/DhanHQ/resources/historical_data.rb +34 -0
- data/lib/DhanHQ/resources/holdings.rb +21 -0
- data/lib/DhanHQ/resources/kill_switch.rb +21 -0
- data/lib/DhanHQ/resources/margin_calculator.rb +22 -0
- data/lib/DhanHQ/resources/market_feed.rb +56 -0
- data/lib/DhanHQ/resources/option_chain.rb +31 -0
- data/lib/DhanHQ/resources/orders.rb +70 -0
- data/lib/DhanHQ/resources/positions.rb +29 -0
- data/lib/DhanHQ/resources/profile.rb +25 -0
- data/lib/DhanHQ/resources/statements.rb +42 -0
- data/lib/DhanHQ/resources/super_orders.rb +46 -0
- data/lib/DhanHQ/resources/trades.rb +23 -0
- data/lib/DhanHQ/version.rb +6 -0
- data/lib/DhanHQ/ws/client.rb +182 -0
- data/lib/DhanHQ/ws/cmd_bus.rb +38 -0
- data/lib/DhanHQ/ws/connection.rb +240 -0
- data/lib/DhanHQ/ws/decoder.rb +83 -0
- data/lib/DhanHQ/ws/errors.rb +0 -0
- data/lib/DhanHQ/ws/orders/client.rb +59 -0
- data/lib/DhanHQ/ws/orders/connection.rb +148 -0
- data/lib/DhanHQ/ws/orders.rb +13 -0
- data/lib/DhanHQ/ws/packets/depth_delta_packet.rb +20 -0
- data/lib/DhanHQ/ws/packets/disconnect_packet.rb +15 -0
- data/lib/DhanHQ/ws/packets/full_packet.rb +40 -0
- data/lib/DhanHQ/ws/packets/header.rb +23 -0
- data/lib/DhanHQ/ws/packets/index_packet.rb +14 -0
- data/lib/DhanHQ/ws/packets/market_depth_level.rb +21 -0
- data/lib/DhanHQ/ws/packets/market_status_packet.rb +14 -0
- data/lib/DhanHQ/ws/packets/oi_packet.rb +15 -0
- data/lib/DhanHQ/ws/packets/prev_close_packet.rb +16 -0
- data/lib/DhanHQ/ws/packets/quote_packet.rb +26 -0
- data/lib/DhanHQ/ws/packets/ticker_packet.rb +16 -0
- data/lib/DhanHQ/ws/registry.rb +46 -0
- data/lib/DhanHQ/ws/segments.rb +75 -0
- data/lib/DhanHQ/ws/singleton_lock.rb +54 -0
- data/lib/DhanHQ/ws/sub_state.rb +59 -0
- data/lib/DhanHQ/ws/websocket_packet_parser.rb +165 -0
- data/lib/DhanHQ/ws.rb +37 -0
- data/lib/DhanHQ.rb +135 -0
- data/lib/ta/technical_analysis.rb +405 -0
- data/sig/DhanHQ.rbs +4 -0
- data/watchlist.csv +3 -0
- 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,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
|