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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "eventmachine"
4
+ require "faye/websocket"
5
+ require "json"
6
+ require "thread" # rubocop:disable Lint/RedundantRequireStatement
7
+
8
+ module DhanHQ
9
+ module WS
10
+ module Orders
11
+ class Connection
12
+ COOL_OFF_429 = 60
13
+ MAX_BACKOFF = 90
14
+
15
+ def initialize(url:, &on_json)
16
+ @url = url
17
+ @on_json = on_json
18
+ @ws = nil
19
+ @stop = false
20
+ @cooloff_until = nil
21
+ end
22
+
23
+ def start
24
+ Thread.new { loop_run }
25
+ self
26
+ end
27
+
28
+ def stop
29
+ @stop = true
30
+ @ws&.close
31
+ end
32
+
33
+ def disconnect!
34
+ # spec does not list a separate disconnect message; just close
35
+ @ws&.close
36
+ end
37
+
38
+ private
39
+
40
+ def loop_run
41
+ backoff = 2.0
42
+ until @stop
43
+ failed = false
44
+ got_429 = false
45
+ sleep (@cooloff_until - Time.now).ceil if @cooloff_until && Time.now < @cooloff_until
46
+
47
+ begin
48
+ failed, got_429 = run_session
49
+ rescue StandardError => e
50
+ DhanHQ.logger&.error("[DhanHQ::WS::Orders] crashed #{e.class} #{e.message}")
51
+ failed = true
52
+ ensure
53
+ break if @stop
54
+
55
+ if got_429
56
+ @cooloff_until = Time.now + COOL_OFF_429
57
+ DhanHQ.logger&.warn("[DhanHQ::WS::Orders] cooling off #{COOL_OFF_429}s due to 429")
58
+ end
59
+
60
+ if failed
61
+ sleep_time = [backoff, MAX_BACKOFF].min
62
+ jitter = rand(0.2 * sleep_time)
63
+ DhanHQ.logger&.warn("[DhanHQ::WS::Orders] reconnecting in #{(sleep_time + jitter).round(1)}s")
64
+ sleep(sleep_time + jitter)
65
+ backoff *= 2.0
66
+ else
67
+ backoff = 2.0
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def run_session
74
+ failed = false
75
+ got_429 = false
76
+ latch = Queue.new
77
+
78
+ runner = proc do |stopper|
79
+ @ws = Faye::WebSocket::Client.new(@url, nil, headers: default_headers)
80
+
81
+ @ws.on :open do |_|
82
+ DhanHQ.logger&.info("[DhanHQ::WS::Orders] open")
83
+ send_login
84
+ end
85
+
86
+ @ws.on :message do |ev|
87
+ begin
88
+ msg = JSON.parse(ev.data, symbolize_names: true)
89
+ @on_json&.call(msg)
90
+ rescue StandardError => e
91
+ DhanHQ.logger&.error("[DhanHQ::WS::Orders] bad JSON #{e.class}: #{e.message}")
92
+ end
93
+ end
94
+
95
+ @ws.on :close do |ev|
96
+ DhanHQ.logger&.warn("[DhanHQ::WS::Orders] close #{ev.code} #{ev.reason}")
97
+ failed = (ev.code != 1000)
98
+ got_429 = ev.reason.to_s.include?("429")
99
+ latch << true
100
+ stopper.call
101
+ end
102
+
103
+ @ws.on :error do |ev|
104
+ DhanHQ.logger&.error("[DhanHQ::WS::Orders] error #{ev.message}")
105
+ failed = true
106
+ end
107
+ end
108
+
109
+ if EM.reactor_running?
110
+ EM.schedule { runner.call(-> {}) }
111
+ else
112
+ EM.run do
113
+ runner.call(-> { EM.stop })
114
+ end
115
+ end
116
+
117
+ latch.pop
118
+
119
+ [failed, got_429]
120
+ end
121
+
122
+ def default_headers
123
+ { "User-Agent" => "dhanhq-ruby/#{defined?(DhanHQ::VERSION) ? DhanHQ::VERSION : "dev"} Ruby/#{RUBY_VERSION}" }
124
+ end
125
+
126
+ def send_login
127
+ cfg = DhanHQ.configuration
128
+ if cfg.ws_user_type.to_s.upcase == "PARTNER"
129
+ payload = {
130
+ LoginReq: { MsgCode: 42, ClientId: cfg.partner_id },
131
+ UserType: "PARTNER",
132
+ Secret: cfg.partner_secret
133
+ }
134
+ else
135
+ token = cfg.access_token or raise "DhanHQ.access_token not set"
136
+ cid = cfg.client_id or raise "DhanHQ.client_id not set"
137
+ payload = {
138
+ LoginReq: { MsgCode: 42, ClientId: cid, Token: token },
139
+ UserType: "SELF"
140
+ }
141
+ end
142
+ DhanHQ.logger&.info("[DhanHQ::WS::Orders] LOGIN -> (#{payload[:UserType]})")
143
+ @ws.send(payload.to_json)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "orders/client"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module Orders
8
+ def self.connect(&on_update)
9
+ Client.new.start.on(:update, &on_update)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # Depth delta payload (20 bytes): see Dhan spec
9
+ class DepthDeltaPacket < BinData::Record
10
+ endian :little
11
+ uint32 :bid_quantity # 4
12
+ uint32 :ask_quantity # 4
13
+ uint16 :no_of_bid_orders # 2
14
+ uint16 :no_of_ask_orders # 2
15
+ float :bid_price # 4
16
+ float :ask_price # 4
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # Disconnect payload (2 bytes). Your earlier code read signed big-endian; make it explicit.
9
+ class DisconnectPacket < BinData::Record
10
+ endian :big
11
+ uint16 :code
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/dhanhq/ws/packets/full_packet.rb
4
+ require "bindata"
5
+ require_relative "market_depth_level"
6
+ module DhanHQ
7
+ module WS
8
+ module Packets
9
+ # Binary definition of the "full" market depth packet.
10
+ class FullPacket < BinData::Record
11
+ endian :little
12
+
13
+ # Core Quote Data
14
+ float :ltp # 4 bytes
15
+ uint16 :last_trade_qty # 2 bytes
16
+ uint32 :ltt # 4 bytes (epoch in seconds)
17
+ float :atp # 4 bytes
18
+ uint32 :volume # 4 bytes
19
+ int32 :total_sell_qty # 4 bytes
20
+ int32 :total_buy_qty # 4 bytes
21
+
22
+ # Open Interest & Extremes
23
+ int32 :open_interest # 4 bytes
24
+ int32 :highest_oi # 4 bytes (optional, F&O only)
25
+ int32 :lowest_oi # 4 bytes (optional, F&O only)
26
+
27
+ # OHLC Values
28
+ float :day_open # 4 bytes
29
+ float :day_close # 4 bytes
30
+ float :day_high # 4 bytes
31
+ float :day_low # 4 bytes
32
+
33
+ # Market Depth (5 levels × 20 bytes)
34
+ array :market_depth, initial_length: 5 do
35
+ market_depth_level
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ # Binary structures backing the streaming feed decoder.
8
+ module Packets
9
+ # Binary representation of the 8-byte frame header.
10
+ class Header < BinData::Record
11
+ endian :big # Default to big-endian for majority fields
12
+
13
+ uint8 :feed_response_code # Byte 1
14
+ uint16 :message_length # Bytes 2–3
15
+ uint8 :exchange_segment # Byte 4
16
+
17
+ # Parse security_id separately using little-endian
18
+ # This works because `BinData` allows override
19
+ int32le :security_id # Bytes 5–8
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module WS
5
+ module Packets
6
+ # Layout not in public docs; keep raw bytes for now
7
+ class IndexPacket
8
+ attr_reader :raw
9
+
10
+ def initialize(raw) = (@raw = raw)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/dhanhq/ws/packets/market_depth_level.rb
4
+ require "bindata"
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # Binary representation of a single depth level in the feed.
9
+ class MarketDepthLevel < BinData::Record
10
+ endian :little
11
+
12
+ uint32 :bid_quantity # 4 bytes
13
+ uint32 :ask_quantity # 4 bytes
14
+ uint16 :no_of_bid_orders # 2 bytes
15
+ uint16 :no_of_ask_orders # 2 bytes
16
+ float :bid_price # 4 bytes
17
+ float :ask_price # 4 bytes
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module WS
5
+ module Packets
6
+ # Layout not in public docs; keep raw bytes for now
7
+ class MarketStatusPacket
8
+ attr_reader :raw
9
+
10
+ def initialize(raw) = (@raw = raw)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # OI payload (4 bytes): int32 little-endian
9
+ class OiPacket < BinData::Record
10
+ endian :little
11
+ int32 :open_interest
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # Prev Close payload (8 bytes): float32 prev_close, int32 oi_prev (little-endian)
9
+ class PrevClosePacket < BinData::Record
10
+ endian :little
11
+ float :prev_close # 4 bytes
12
+ int32 :oi_prev # 4 bytes
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/dhanhq/ws/packets/quote_packet.rb
4
+ require "bindata"
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # Binary definition for quote snapshots emitted by the feed.
9
+ class QuotePacket < BinData::Record
10
+ endian :little
11
+
12
+ float :ltp # 4 bytes
13
+ uint16 :last_trade_qty # 2 bytes
14
+ uint32 :ltt # 4 bytes
15
+ float :atp # 4 bytes
16
+ uint32 :volume # 4 bytes
17
+ int32 :total_sell_qty # 4 bytes
18
+ int32 :total_buy_qty # 4 bytes
19
+ float :day_open # 4 bytes
20
+ float :day_close # 4 bytes
21
+ float :day_high # 4 bytes
22
+ float :day_low # 4 bytes
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module Packets
8
+ # Ticker payload (8 bytes): float32 ltp, int32 ltt (both little-endian)
9
+ class TickerPacket < BinData::Record
10
+ endian :little
11
+ float :ltp # 4 bytes
12
+ int32 :ltt # 4 bytes
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ # Tracks the set of active WebSocket clients so they can be collectively
8
+ # disconnected when required.
9
+ class Registry
10
+ @clients = []
11
+ class << self
12
+ # Registers a client instance with the registry.
13
+ #
14
+ # @param client [DhanHQ::WS::Client]
15
+ # @return [void]
16
+ def register(client)
17
+ @clients << client unless @clients.include?(client)
18
+ end
19
+
20
+ # Removes a client from the registry.
21
+ #
22
+ # @param client [DhanHQ::WS::Client]
23
+ # @return [void]
24
+ def unregister(client)
25
+ @clients.delete(client)
26
+ end
27
+
28
+ # Stops and removes all registered clients.
29
+ #
30
+ # @return [void]
31
+ def stop_all
32
+ @clients.dup.each do |c|
33
+ c.stop
34
+ rescue StandardError
35
+ end
36
+ @clients.clear
37
+ end
38
+ end
39
+ end
40
+
41
+ # convenience API
42
+ def self.disconnect_all_local!
43
+ Registry.stop_all
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module WS
5
+ # Utility helpers for translating between various exchange segment
6
+ # representations used by the streaming API.
7
+ module Segments
8
+ # Canonical enum mapping (per Dhan spec)
9
+ STRING_TO_CODE = {
10
+ "IDX_I" => 0,
11
+ "NSE_EQ" => 1,
12
+ "NSE_FNO" => 2,
13
+ "NSE_CURRENCY" => 3,
14
+ "BSE_EQ" => 4,
15
+ "MCX_COMM" => 5,
16
+ "BSE_CURRENCY" => 7,
17
+ "BSE_FNO" => 8
18
+ }.freeze
19
+
20
+ # Lookup table converting numeric feed codes into exchange strings.
21
+ CODE_TO_STRING = STRING_TO_CODE.invert.freeze
22
+
23
+ # Accepts multiple segment representations and returns the canonical
24
+ # string used by the API.
25
+ #
26
+ # @param segment [String, Symbol, Integer]
27
+ # @return [String]
28
+ def self.to_request_string(segment)
29
+ case segment
30
+ when String
31
+ return segment if STRING_TO_CODE.key?(segment) # already canonical
32
+ return CODE_TO_STRING[segment.to_i] if /\A\d+\z/.match?(segment) # "2" -> "NSE_FNO"
33
+
34
+ STRING_TO_CODE.key(segment) || segment.upcase # e.g. "nse_fno" -> "NSE_FNO"
35
+ when Symbol
36
+ up = segment.to_s.upcase
37
+ STRING_TO_CODE.key(STRING_TO_CODE[up]) || up
38
+ when Integer
39
+ CODE_TO_STRING[segment] || segment.to_s
40
+ else
41
+ segment.to_s
42
+ end
43
+ end
44
+
45
+ # Normalize a single instrument for subscribe/unsubscribe requests.
46
+ # Ensures:
47
+ # - ExchangeSegment is a STRING enum (e.g., "NSE_FNO")
48
+ # - SecurityId is a STRING
49
+ #
50
+ # @param h [Hash]
51
+ # @return [Hash] Normalized instrument hash.
52
+ def self.normalize_instrument(h)
53
+ seg = to_request_string(h[:ExchangeSegment] || h["ExchangeSegment"])
54
+ sid = (h[:SecurityId] || h["SecurityId"]).to_s
55
+ { ExchangeSegment: seg, SecurityId: sid }
56
+ end
57
+
58
+ # Normalizes all instruments in the provided list.
59
+ #
60
+ # @param list [Enumerable<Hash>]
61
+ # @return [Array<Hash>]
62
+ def self.normalize_instruments(list)
63
+ Array(list).map { |h| normalize_instrument(h) }
64
+ end
65
+
66
+ # Converts a numeric response code into the API's segment string.
67
+ #
68
+ # @param code_byte [Integer]
69
+ # @return [String]
70
+ def self.from_code(code_byte)
71
+ CODE_TO_STRING[code_byte] || code_byte.to_s
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module DhanHQ
7
+ module WS
8
+ # File-system based lock to ensure only one WebSocket process is active per
9
+ # credential pair.
10
+ class SingletonLock
11
+ # @param token [String]
12
+ # @param client_id [String]
13
+ def initialize(token:, client_id:)
14
+ key = Digest::SHA256.hexdigest("#{client_id}:#{token}")[0, 12]
15
+ @path = File.expand_path("tmp/dhanhq_ws_#{key}.lock", Dir.pwd)
16
+ FileUtils.mkdir_p(File.dirname(@path))
17
+ @fh = File.open(@path, File::RDWR | File::CREAT, 0o644)
18
+ end
19
+
20
+ # Attempts to acquire the lock for the current process.
21
+ #
22
+ # @raise [RuntimeError] When another process already holds the lock.
23
+ # @return [Boolean] true when the lock is obtained.
24
+ def acquire!
25
+ unless @fh.flock(File::LOCK_NB | File::LOCK_EX)
26
+ pid = begin
27
+ @fh.read.to_i
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ raise "Another DhanHQ WS process is active (pid=#{pid}). Stop it first."
32
+ end
33
+ @fh.rewind
34
+ @fh.truncate(0)
35
+ @fh.write(Process.pid.to_s)
36
+ @fh.flush
37
+ true
38
+ end
39
+
40
+ # Releases the lock and removes the lock file.
41
+ #
42
+ # @return [void]
43
+ def release!
44
+ @fh.flock(File::LOCK_UN)
45
+ @fh.close
46
+ begin
47
+ File.delete(@path)
48
+ rescue StandardError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ # Maintains the current subscription state and performs diffing so that the
8
+ # client only sends incremental subscribe/unsubscribe requests.
9
+ class SubState
10
+ def initialize
11
+ @set = Concurrent::Set.new
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Filters out instruments that are already subscribed.
16
+ #
17
+ # @param list [Array<Hash>]
18
+ # @return [Array<Hash>] Instruments that still need to be subscribed.
19
+ def want_sub(list)
20
+ @mutex.synchronize { list.reject { |i| @set.include?(key_for(i)) } }
21
+ end
22
+
23
+ # Marks the provided instruments as subscribed.
24
+ #
25
+ # @param list [Array<Hash>]
26
+ # @return [void]
27
+ def mark_subscribed!(list)
28
+ @mutex.synchronize { list.each { |i| @set.add(key_for(i)) } }
29
+ end
30
+
31
+ # Filters the instruments that are currently subscribed.
32
+ #
33
+ # @param list [Array<Hash>]
34
+ # @return [Array<Hash>] Instruments that can be unsubscribed.
35
+ def want_unsub(list)
36
+ @mutex.synchronize { list.select { |i| @set.include?(key_for(i)) } }
37
+ end
38
+
39
+ # Marks the provided instruments as unsubscribed.
40
+ #
41
+ # @param list [Array<Hash>]
42
+ # @return [void]
43
+ def mark_unsubscribed!(list)
44
+ @mutex.synchronize { list.each { |i| @set.delete(key_for(i)) } }
45
+ end
46
+
47
+ # Returns the current subscription snapshot.
48
+ #
49
+ # @return [Array<String>] Instrument identifiers in "SEGMENT:SECID" form.
50
+ def snapshot
51
+ @mutex.synchronize { @set.to_a }
52
+ end
53
+
54
+ private
55
+
56
+ def key_for(i) = "#{i[:ExchangeSegment]}:#{i[:SecurityId]}"
57
+ end
58
+ end
59
+ end