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,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,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,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,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
|