DhanHQ 3.1.0 → 3.2.1

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +73 -0
  4. data/README.md +241 -3
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +5 -4
  8. data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
  9. data/docs/LIVE_ORDER_UPDATES.md +5 -10
  10. data/docs/RAILS_INTEGRATION.md +1 -1
  11. data/docs/RELEASE_GUIDE.md +13 -2
  12. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
  13. data/docs/WEBSOCKET_INTEGRATION.md +13 -20
  14. data/docs/WEBSOCKET_PROTOCOL.md +7 -3
  15. data/lib/DhanHQ/agent/key_coercion.rb +36 -0
  16. data/lib/DhanHQ/agent/tool.rb +37 -0
  17. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  18. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  19. data/lib/DhanHQ/agent/tool_registry.rb +17 -261
  20. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  21. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  22. data/lib/DhanHQ/client.rb +105 -30
  23. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +101 -0
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  28. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  29. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  30. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  31. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  32. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  33. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  34. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  35. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  36. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  37. data/lib/DhanHQ/models/alert_order.rb +6 -3
  38. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  39. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  40. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  41. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  42. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  43. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  44. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  45. data/lib/DhanHQ/models/margin.rb +5 -1
  46. data/lib/DhanHQ/models/multi_order.rb +130 -0
  47. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  48. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  49. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  50. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  51. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  52. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  53. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  55. data/lib/DhanHQ/version.rb +1 -1
  56. data/lib/DhanHQ/write_paths.rb +57 -0
  57. data/lib/DhanHQ/ws/client.rb +117 -2
  58. data/lib/DhanHQ/ws/connection.rb +40 -11
  59. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  60. data/lib/dhan_hq.rb +4 -0
  61. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  62. data/skills/dhanhq-ruby/SKILL.md +2 -1
  63. data/skills/dhanhq-ruby/examples/order_management.rb +7 -0
  64. data/skills/dhanhq-ruby/references/common-workflows.md +8 -8
  65. data/skills/dhanhq-ruby/references/instruments.md +7 -1
  66. data/skills/dhanhq-ruby/references/orders.md +2 -0
  67. metadata +30 -10
  68. data/.rspec +0 -3
  69. data/.rubocop.yml +0 -50
  70. data/.rubocop_todo.yml +0 -217
  71. data/Rakefile +0 -14
  72. data/TAGS +0 -10
  73. data/core +0 -0
  74. data/diagram.html +0 -184
  75. data/watchlist.csv +0 -3
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the Global Stocks pre-trade calculators.
7
+ #
8
+ # POST /v2/globalstocks/margincalculator — margin required for an order
9
+ # POST /v2/globalstocks/transEstimate — charges applicable to an order
10
+ #
11
+ # Both endpoints take the same request body. The API documents +price+ and
12
+ # +quantity+ as strings, so numeric input is stringified before sending.
13
+ class MarginCalculator < BaseAPI
14
+ API_TYPE = :non_trading_api
15
+ HTTP_PATH = "/v2/globalstocks"
16
+
17
+ # Calculates the margin required for a Global Stocks order.
18
+ #
19
+ # @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
20
+ # @return [Hash] Margin breakdown.
21
+ def calculate(params)
22
+ post("/margincalculator", params: wire_params(params))
23
+ end
24
+
25
+ # Estimates the charges applicable to a Global Stocks order.
26
+ #
27
+ # @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
28
+ # @return [Hash] Charge breakdown.
29
+ def estimate(params)
30
+ post("/transEstimate", params: wire_params(params))
31
+ end
32
+
33
+ private
34
+
35
+ # Validates against the shared estimator contract, then renders the numeric
36
+ # fields as strings the way the API expects them.
37
+ def wire_params(params)
38
+ validated = validate!(params)
39
+ validated.merge(
40
+ price: format_number(validated[:price]),
41
+ quantity: format_number(validated[:quantity])
42
+ )
43
+ end
44
+
45
+ # Fields the API documents as strings but which are validated as numbers.
46
+ NUMERIC_KEYS = %i[price quantity].freeze
47
+ private_constant :NUMERIC_KEYS
48
+
49
+ def validate!(params)
50
+ attrs = snake_case(params).each_with_object({}) do |(key, value), out|
51
+ out[key] = NUMERIC_KEYS.include?(key) ? Float(value) : value
52
+ end
53
+ result = Contracts::GlobalStocksEstimatorContract.new.call(attrs)
54
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
55
+
56
+ result.to_h
57
+ rescue ArgumentError, TypeError => e
58
+ raise DhanHQ::ValidationError, "Invalid parameters: price and quantity must be numeric (#{e.message})"
59
+ end
60
+
61
+ # Renders 180.0 as "180" and 1.5 as "1.5" so the payload matches the
62
+ # documented examples rather than leaking Ruby float formatting.
63
+ def format_number(value)
64
+ float = Float(value)
65
+ float == float.to_i ? float.to_i.to_s : float.to_s
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the US market session status.
7
+ #
8
+ # GET /v2/globalstocks/marketstatus
9
+ class MarketStatus < BaseAPI
10
+ API_TYPE = :non_trading_api
11
+ HTTP_PATH = "/v2/globalstocks/marketstatus"
12
+
13
+ # Retrieves the current US market status and session timings.
14
+ #
15
+ # @return [Hash] `{ status:, marketOpenTime:, marketCloseTime:, holidayFlag: }`
16
+ def fetch
17
+ get("")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../concerns/order_audit"
4
+
5
+ module DhanHQ
6
+ module Resources
7
+ module GlobalStocks
8
+ # Resource client for the Global Stocks (US equities) order endpoints.
9
+ #
10
+ # POST /v2/globalstocks/orders
11
+ # PUT /v2/globalstocks/orders/{order-id}
12
+ # DELETE /v2/globalstocks/orders/{order-id}
13
+ # GET /v2/globalstocks/orders
14
+ # GET /v2/globalstocks/orders/{order-id}
15
+ #
16
+ # Writes go through the same {DhanHQ::Concerns::OrderAudit} guardrails as
17
+ # domestic orders: +ENV["LIVE_TRADING"]="true"+ is required, and every attempt
18
+ # emits a structured audit log line.
19
+ #
20
+ # The pre-trade {DhanHQ::Risk::Pipeline} is intentionally *not* run here. Its
21
+ # checks (lot-size multiples, ASM/GSM surveillance lists, F&O product support,
22
+ # NSE/BSE market hours) resolve instruments from the Indian scrip master, which
23
+ # does not contain US securities.
24
+ #
25
+ # @see https://dhanhq.co/docs/v2/ Global Stocks section
26
+ class Orders < BaseAPI
27
+ include DhanHQ::Concerns::OrderAudit
28
+
29
+ API_TYPE = :order_api
30
+ HTTP_PATH = "/v2/globalstocks/orders"
31
+
32
+ # Places a new Global Stocks order.
33
+ #
34
+ # @param params [Hash] Order attributes in snake_case. See
35
+ # {DhanHQ::Contracts::GlobalStocksPlaceOrderContract} for the accepted keys.
36
+ # @return [Hash] `{ orderId:, orderStatus: }`
37
+ # @raise [DhanHQ::LiveTradingDisabledError] Unless LIVE_TRADING is enabled.
38
+ # @raise [DhanHQ::ValidationError] When the payload fails validation.
39
+ def create(params)
40
+ ensure_live_trading!
41
+ log_order_context("DHAN_GLOBAL_ORDER_ATTEMPT", params)
42
+ validate_place_order!(params)
43
+ post("", params: params)
44
+ end
45
+
46
+ # Modifies a pending Global Stocks order.
47
+ #
48
+ # @param order_id [String]
49
+ # @param params [Hash] Fields to change, in snake_case.
50
+ # @return [Hash] `{ orderId:, orderStatus: }`
51
+ def update(order_id, params)
52
+ ensure_live_trading!
53
+ log_order_context("DHAN_GLOBAL_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
54
+ validate_modify_order!(params.merge(order_id: order_id))
55
+ put("/#{order_id}", params: params)
56
+ end
57
+
58
+ # Cancels a pending Global Stocks order.
59
+ #
60
+ # @param order_id [String]
61
+ # @return [Hash] `{ orderId:, orderStatus: }`
62
+ def cancel(order_id)
63
+ ensure_live_trading!
64
+ log_order_context("DHAN_GLOBAL_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
65
+ delete("/#{order_id}")
66
+ end
67
+
68
+ # Retrieves the current trading day's Global Stocks order book.
69
+ #
70
+ # @return [Array<Hash>]
71
+ def all
72
+ get("")
73
+ end
74
+
75
+ # Retrieves a single Global Stocks order by its Dhan order id.
76
+ #
77
+ # @param order_id [String]
78
+ # @return [Hash]
79
+ def find(order_id)
80
+ get("/#{order_id}")
81
+ end
82
+
83
+ private
84
+
85
+ def validate_place_order!(params)
86
+ result = Contracts::GlobalStocksPlaceOrderContract.new.call(coerce_for_validation(params))
87
+ raise_validation_error!(result) unless result.success?
88
+ end
89
+
90
+ def validate_modify_order!(params)
91
+ result = Contracts::GlobalStocksModifyOrderContract.new.call(coerce_for_validation(params))
92
+ raise_validation_error!(result) unless result.success?
93
+ end
94
+
95
+ # Global Stocks quantities and prices are floats (fractional shares are
96
+ # supported), so integers handed in by callers are widened before validation.
97
+ def coerce_for_validation(params)
98
+ snake_case(params).each_with_object({}) do |(key, value), out|
99
+ out[key] = FLOAT_KEYS.include?(key) && value.is_a?(Integer) ? value.to_f : value
100
+ end
101
+ end
102
+
103
+ FLOAT_KEYS = %i[quantity price trigger_price stop_loss_price target_price amount].freeze
104
+ private_constant :FLOAT_KEYS
105
+
106
+ def raise_validation_error!(result)
107
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the Global Stocks trade book.
7
+ #
8
+ # GET /v2/globalstocks/trades
9
+ # GET /v2/globalstocks/trades/{security-id}
10
+ class Trades < BaseAPI
11
+ API_TYPE = :order_api
12
+ HTTP_PATH = "/v2/globalstocks/trades"
13
+
14
+ # Retrieves the current trading day's executed Global Stocks trades.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def all
18
+ get("")
19
+ end
20
+
21
+ # Retrieves executed Global Stocks trades for a single security.
22
+ #
23
+ # @param security_id [String]
24
+ # @return [Array<Hash>]
25
+ def by_security_id(security_id)
26
+ get("/#{security_id}")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../concerns/order_audit"
4
+
5
+ module DhanHQ
6
+ module Resources
7
+ # Resource for basket orders: POST /v2/alerts/multi/orders.
8
+ #
9
+ # Places up to {DhanHQ::Contracts::MultiOrderContract::MAX_ORDERS} unconditional
10
+ # orders in a single request. Unlike {AlertOrders} there is no trigger condition —
11
+ # every leg is sent to the exchange immediately.
12
+ #
13
+ # @see https://dhanhq.co/docs/v2/ Conditional and Multi Order section
14
+ class MultiOrders < BaseAPI
15
+ include DhanHQ::Concerns::OrderAudit
16
+
17
+ API_TYPE = :order_api
18
+ HTTP_PATH = "/v2/alerts/multi/orders"
19
+
20
+ # Places a basket of orders.
21
+ #
22
+ # @param orders [Array<Hash>] Order legs in snake_case. Each leg requires
23
+ # +:sequence+, +:transaction_type+ and +:exchange_segment+.
24
+ # @param dhan_client_id [String, nil] Defaults to the configured client id.
25
+ # @return [Hash] `{ orders: [{ orderId:, sequence:, orderStatus: }, ...] }`
26
+ # @raise [DhanHQ::LiveTradingDisabledError] Unless LIVE_TRADING is enabled.
27
+ # @raise [DhanHQ::ValidationError] When any leg fails validation.
28
+ def create(orders, dhan_client_id: nil)
29
+ ensure_live_trading!
30
+
31
+ legs = Array(orders).map { |leg| snake_case(leg) }
32
+ payload = { dhan_client_id: dhan_client_id || DhanHQ.configuration&.client_id, orders: legs }.compact
33
+
34
+ validate!(payload)
35
+ legs.each { |leg| run_risk_checks!(leg) }
36
+ log_multi_order_context(legs)
37
+
38
+ # BaseAPI camelizes only the top level, which would leave the leg fields as
39
+ # `transaction_type`/`security_id` inside `orders` and get the basket rejected.
40
+ post("", params: payload.merge(orders: deep_camelize_keys(legs)))
41
+ end
42
+
43
+ private
44
+
45
+ def validate!(payload)
46
+ result = Contracts::MultiOrderContract.new.call(payload)
47
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
48
+ end
49
+
50
+ # Emits one audit line per leg so each order in the basket is traceable.
51
+ def log_multi_order_context(legs)
52
+ legs.each do |leg|
53
+ log_order_context("DHAN_MULTI_ORDER_ATTEMPT", leg)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -51,14 +51,12 @@ module DhanHQ
51
51
  # @param type [Symbol] :equity or :options
52
52
  # @return [true] if all checks pass
53
53
  # @raise [DhanHQ::RiskViolation] on first failure
54
- # rubocop:disable Naming/PredicateMethod
55
54
  def self.run!(instrument:, args:, now: Time.now, type: :equity)
56
55
  run_checks!(CHECKS, instrument, args, now)
57
56
  run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
58
57
  run_checks!(DAILY_CHECKS, instrument, args, now)
59
58
  true
60
59
  end
61
- # rubocop:enable Naming/PredicateMethod
62
60
 
63
61
  def self.run_checks!(checks, instrument, args, now)
64
62
  checks.each do |check|
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "3.1.0"
5
+ VERSION = "3.2.1"
6
6
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Classifies request paths by whether they change account state.
5
+ #
6
+ # The distinction that matters on this API is **mutating vs read-only**, not GET vs
7
+ # POST: the option chain, market feed, historical charts and the margin calculators
8
+ # are all read-only POSTs. Both dry-run simulation and non-idempotent retry gating
9
+ # need that answer, so it lives here rather than in {DhanHQ::Client}.
10
+ module WritePaths
11
+ # HTTP methods that can change server-side state.
12
+ WRITE_METHODS = %i[post put patch delete].freeze
13
+
14
+ # Path of the basket order endpoint, which answers with one result per leg
15
+ # rather than a single order id.
16
+ MULTI_ORDER_PATH = "/v2/alerts/multi/orders"
17
+
18
+ module_function
19
+
20
+ # True when this request changes real account state.
21
+ #
22
+ # @param method [Symbol] HTTP method
23
+ # @param path [String, nil] Request path
24
+ # @return [Boolean]
25
+ def mutating?(method, path)
26
+ return false unless WRITE_METHODS.include?(method)
27
+
28
+ match?(path, DhanHQ::Constants::MUTATING_PATH_PREFIXES)
29
+ end
30
+
31
+ # True when this request places one or more orders.
32
+ #
33
+ # @param method [Symbol] HTTP method
34
+ # @param path [String, nil] Request path
35
+ # @return [Boolean]
36
+ def order_placement?(method, path)
37
+ return false unless method == :post
38
+
39
+ match?(path, DhanHQ::Constants::ORDER_PLACEMENT_PATH_PREFIXES)
40
+ end
41
+
42
+ # True for the basket order endpoint.
43
+ #
44
+ # @param path [String, nil] Request path
45
+ # @return [Boolean]
46
+ def multi_order?(path)
47
+ path.to_s.start_with?(MULTI_ORDER_PATH)
48
+ end
49
+
50
+ def match?(path, prefixes)
51
+ return false if path.nil?
52
+
53
+ prefixes.any? { |prefix| path.start_with?(prefix) }
54
+ end
55
+ private_class_method :match?
56
+ end
57
+ end
@@ -26,6 +26,9 @@ module DhanHQ
26
26
  @state = SubState.new
27
27
  @callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
28
28
  @started = Concurrent::AtomicBoolean.new(false)
29
+ @last_message_at = Concurrent::AtomicReference.new(nil)
30
+ @connected_at = Concurrent::AtomicReference.new(nil)
31
+ @reconnects = Concurrent::AtomicFixnum.new(0)
29
32
 
30
33
  token = DhanHQ.configuration.resolved_access_token
31
34
  raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
@@ -43,7 +46,8 @@ module DhanHQ
43
46
  return self if @started.true?
44
47
 
45
48
  @started.make_true
46
- @conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state) do |binary|
49
+ @conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state,
50
+ on_event: method(:handle_connection_event)) do |binary|
47
51
  tick = Decoder.decode(binary)
48
52
  emit(:tick, tick) if tick
49
53
  end
@@ -90,6 +94,80 @@ module DhanHQ
90
94
  @conn&.open? || false
91
95
  end
92
96
 
97
+ # Timestamp of the most recently received frame.
98
+ #
99
+ # A feed can be "connected" and still be dead — the socket stays open while
100
+ # the server has stopped publishing. Long-running daemons should watch this
101
+ # rather than {#connected?} alone.
102
+ #
103
+ # @return [Time, nil] nil until the first frame arrives.
104
+ def last_message_at
105
+ @last_message_at.get
106
+ end
107
+
108
+ # Seconds since the last frame arrived.
109
+ #
110
+ # @return [Float, nil] nil until the first frame arrives.
111
+ def seconds_since_last_message
112
+ stamp = last_message_at
113
+ return nil unless stamp
114
+
115
+ Time.now - stamp
116
+ end
117
+
118
+ # Whether the feed looks alive: connected and delivering frames recently.
119
+ #
120
+ # @param stale_after [Numeric] Seconds of silence after which the feed is
121
+ # considered stale. The server pings roughly every 10 seconds, so the
122
+ # default allows several missed intervals.
123
+ # @return [Boolean]
124
+ def healthy?(stale_after: 45)
125
+ return false unless connected?
126
+
127
+ idle = seconds_since_last_message
128
+ idle.nil? || idle <= stale_after
129
+ end
130
+
131
+ # Timestamp of the current connection's establishment.
132
+ #
133
+ # @return [Time, nil]
134
+ def connected_at
135
+ @connected_at.get
136
+ end
137
+
138
+ # Number of times the connection has been re-established since {#start}.
139
+ #
140
+ # @return [Integer]
141
+ def reconnect_count
142
+ @reconnects.value
143
+ end
144
+
145
+ # Instruments currently subscribed, as `"SEGMENT:SECURITY_ID"` strings.
146
+ #
147
+ # Survives reconnects: the connection replays this set on every new session.
148
+ #
149
+ # @return [Array<String>]
150
+ def subscriptions
151
+ @state.snapshot
152
+ end
153
+
154
+ # Point-in-time health snapshot, suitable for a monitoring endpoint or log line.
155
+ #
156
+ # @return [Hash]
157
+ def health
158
+ {
159
+ mode: @mode,
160
+ started: @started.true?,
161
+ connected: connected?,
162
+ healthy: healthy?,
163
+ connected_at: connected_at,
164
+ last_message_at: last_message_at,
165
+ seconds_since_last_message: seconds_since_last_message,
166
+ reconnect_count: reconnect_count,
167
+ subscription_count: subscriptions.size
168
+ }
169
+ end
170
+
93
171
  # Subscribes to updates for a single instrument.
94
172
  #
95
173
  # @param segment [String, Symbol, Integer]
@@ -154,9 +232,27 @@ module DhanHQ
154
232
 
155
233
  # Registers a callback for a given event.
156
234
  #
157
- # @param event [Symbol] Event name (:tick, :open, :close, :error).
235
+ # Supported events:
236
+ #
237
+ # - +:tick+ — a decoded market data packet.
238
+ # - +:open+ — the socket connected. Payload: `{ resubscribed: [...] }`.
239
+ # - +:reconnect+ — the socket re-connected after a drop. Payload:
240
+ # `{ attempt: Integer, resubscribed: [...] }`. Subscriptions are replayed
241
+ # automatically before this fires; use it to re-seed derived state such as
242
+ # candle builders or to alert.
243
+ # - +:close+ — the socket closed. Payload:
244
+ # `{ code:, reason:, reconnecting: Boolean }`.
245
+ # - +:error+ — a socket-level error. Payload: the error message.
246
+ #
247
+ # @param event [Symbol] Event name.
158
248
  # @yieldparam payload [Object] Event payload.
159
249
  # @return [DhanHQ::WS::Client] self.
250
+ #
251
+ # @example React to a reconnect
252
+ # client.on(:reconnect) do |info|
253
+ # logger.warn "feed reconnected (attempt #{info[:attempt]}), " \
254
+ # "#{info[:resubscribed].size} instruments restored"
255
+ # end
160
256
  def on(event, &blk)
161
257
  @callbacks[event] << blk
162
258
  self
@@ -164,6 +260,25 @@ module DhanHQ
164
260
 
165
261
  private
166
262
 
263
+ # Bridges connection-level lifecycle events onto the client's callback bus and
264
+ # updates the health counters. +:message+ is internal bookkeeping only — the
265
+ # decoded payload reaches users as +:tick+.
266
+ def handle_connection_event(event, payload)
267
+ case event
268
+ when :message
269
+ @last_message_at.set(Time.now)
270
+ return
271
+ when :open
272
+ @connected_at.set(Time.now)
273
+ when :reconnect
274
+ @reconnects.increment
275
+ when :close
276
+ @connected_at.set(nil)
277
+ end
278
+
279
+ emit(event, payload)
280
+ end
281
+
167
282
  def prune(hash) = { ExchangeSegment: hash[:ExchangeSegment], SecurityId: hash[:SecurityId] }
168
283
 
169
284
  def emit(event, payload)
@@ -23,13 +23,18 @@ module DhanHQ
23
23
  # @param bus [DhanHQ::WS::CmdBus] Command queue feeding subscription
24
24
  # changes.
25
25
  # @param state [DhanHQ::WS::SubState] Tracks subscription status.
26
+ # @param on_event [Proc, nil] Optional callable invoked as
27
+ # +on_event.call(event, payload)+ for lifecycle events (+:open+, +:close+,
28
+ # +:error+, +:reconnect+). Lets the owning client surface connection state
29
+ # to user callbacks.
26
30
  # @yield [binary]
27
31
  # @yieldparam binary [String] Raw binary frame received from the socket.
28
- def initialize(url:, mode:, bus:, state:, &on_binary)
32
+ def initialize(url:, mode:, bus:, state:, on_event: nil, &on_binary)
29
33
  @url = url
30
34
  @mode = mode
31
35
  @bus = bus
32
36
  @state = state
37
+ @on_event = on_event
33
38
  @on_binary = on_binary
34
39
  @stop = false
35
40
  @stopping = false
@@ -90,11 +95,41 @@ module DhanHQ
90
95
 
91
96
  private
92
97
 
98
+ # Notifies the owning client of a lifecycle event. Never lets a user callback
99
+ # take down the connection thread.
100
+ def notify(event, payload = nil)
101
+ @on_event&.call(event, payload)
102
+ rescue StandardError => e
103
+ DhanHQ.logger&.error("[DhanHQ::WS] on_event(#{event}) raised #{e.class}: #{e.message}")
104
+ end
105
+
106
+ # Restores subscriptions and starts the command drain for a freshly opened
107
+ # socket, then reports the transition.
108
+ #
109
+ # @param session [Integer] 1-based index of this connection attempt. Anything
110
+ # past the first is a reconnect.
111
+ def handle_open(session)
112
+ DhanHQ.logger&.info("[DhanHQ::WS] open")
113
+
114
+ # The server keeps no subscription state across connections, so the desired set
115
+ # is replayed on every new session. See SubState#resubscribe_payload.
116
+ snapshot = @state.resubscribe_payload
117
+ send_sub(snapshot) unless snapshot.empty?
118
+ @timer = EM.add_periodic_timer(0.25) { drain_and_send }
119
+
120
+ # :reconnect lets callers re-seed derived state (candle builders, caches)
121
+ # and see exactly which instruments were restored for them.
122
+ notify(:reconnect, { attempt: session - 1, resubscribed: snapshot }) if session > 1
123
+ notify(:open, { resubscribed: snapshot })
124
+ end
125
+
93
126
  def loop_run
94
127
  backoff = 2.0
128
+ sessions = 0
95
129
  until @stop
96
130
  failed = false
97
131
  got_429 = false
132
+ sessions += 1
98
133
 
99
134
  # respect any active cool-off window
100
135
  sleep (@cooloff_until - Time.now).ceil if @cooloff_until && Time.now < @cooloff_until
@@ -103,18 +138,10 @@ module DhanHQ
103
138
  EM.run do
104
139
  @ws = Faye::WebSocket::Client.new(@url, nil, headers: default_headers)
105
140
 
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
141
+ @ws.on(:open) { |_| handle_open(sessions) }
116
142
 
117
143
  @ws.on :message do |ev|
144
+ notify(:message, nil)
118
145
  @on_binary&.call(ev.data) # raw frames to decoder
119
146
  end
120
147
 
@@ -131,12 +158,14 @@ module DhanHQ
131
158
  failed = (ev.code != 1000)
132
159
  got_429 = ev.reason.to_s.include?("429")
133
160
  end
161
+ notify(:close, { code: ev.code, reason: ev.reason, reconnecting: failed })
134
162
  EM.stop
135
163
  end
136
164
 
137
165
  @ws.on :error do |ev|
138
166
  DhanHQ.logger&.error("[DhanHQ::WS] error #{ev.message}")
139
167
  failed = true
168
+ notify(:error, ev.message)
140
169
  end
141
170
  end
142
171
  rescue StandardError => e
@@ -51,6 +51,20 @@ module DhanHQ
51
51
  @mutex.synchronize { @set.to_a }
52
52
  end
53
53
 
54
+ # The desired subscriptions rendered as instrument hashes, ready to be sent as a
55
+ # subscribe frame.
56
+ #
57
+ # The server keeps no subscription state across connections, so a reconnecting
58
+ # client replays this on every new session.
59
+ #
60
+ # @return [Array<Hash>] Instruments with +:ExchangeSegment+ and +:SecurityId+.
61
+ def resubscribe_payload
62
+ snapshot.map do |key|
63
+ segment, security_id = key.split(":")
64
+ { ExchangeSegment: segment, SecurityId: security_id }
65
+ end
66
+ end
67
+
54
68
  private
55
69
 
56
70
  def key_for(instrument) = "#{instrument[:ExchangeSegment]}:#{instrument[:SecurityId]}"
data/lib/dhan_hq.rb CHANGED
@@ -26,6 +26,10 @@ module DhanHQ
26
26
  LOADER = Zeitwerk::Loader.new
27
27
  LOADER.tag = "dhanhq"
28
28
  LOADER.inflector.inflect(
29
+ # Without this, Zeitwerk looks for DhanHQ::Ai while ai.rb defines DhanHQ::AI, so
30
+ # DhanHQ::AI::PromptHelpers raised NameError unless something had already loaded
31
+ # the file by hand — which only mcp/server.rb did.
32
+ "ai" => "AI",
29
33
  "api_helper" => "APIHelper",
30
34
  "auth_api" => "AuthAPI",
31
35
  "base_api" => "BaseAPI",