DhanHQ 3.0.1 → 3.2.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +98 -0
  4. data/README.md +392 -6
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +8 -6
  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/exe/dhanhq-mcp +7 -0
  16. data/lib/DhanHQ/agent/key_coercion.rb +36 -0
  17. data/lib/DhanHQ/agent/tool.rb +37 -0
  18. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  19. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  20. data/lib/DhanHQ/agent/tool_registry.rb +17 -212
  21. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  22. data/lib/DhanHQ/client.rb +58 -2
  23. data/lib/DhanHQ/concerns/order_audit.rb +74 -1
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +104 -2
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  28. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  29. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  30. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  31. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  32. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  33. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
  34. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  35. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  36. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  37. data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
  38. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  39. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  40. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  41. data/lib/DhanHQ/mcp/server.rb +172 -9
  42. data/lib/DhanHQ/models/alert_order.rb +5 -2
  43. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  44. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  45. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  46. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  47. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  48. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  49. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  50. data/lib/DhanHQ/models/instrument.rb +44 -14
  51. data/lib/DhanHQ/models/margin.rb +5 -1
  52. data/lib/DhanHQ/models/multi_order.rb +130 -0
  53. data/lib/DhanHQ/rate_limiter.rb +5 -3
  54. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  55. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  56. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  57. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  58. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  59. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  60. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  61. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  62. data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
  63. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  64. data/lib/DhanHQ/resources/orders.rb +2 -0
  65. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  66. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  67. data/lib/DhanHQ/resources/twap_orders.rb +1 -0
  68. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  69. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  70. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  71. data/lib/DhanHQ/risk/pipeline.rb +8 -1
  72. data/lib/DhanHQ/skills/base.rb +54 -3
  73. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  74. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  75. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
  76. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  77. data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
  78. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  79. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  80. data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
  81. data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
  82. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  83. data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/write_paths.rb +57 -0
  86. data/lib/DhanHQ/ws/client.rb +117 -2
  87. data/lib/DhanHQ/ws/connection.rb +40 -11
  88. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  89. data/lib/dhan_hq.rb +47 -0
  90. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  91. metadata +41 -15
  92. data/.rspec +0 -3
  93. data/.rubocop.yml +0 -48
  94. data/.rubocop_todo.yml +0 -217
  95. data/AGENTS.md +0 -23
  96. data/CODE_OF_CONDUCT.md +0 -132
  97. data/Rakefile +0 -14
  98. data/TAGS +0 -10
  99. data/core +0 -0
  100. data/diagram.html +0 -184
  101. data/skills/dhanhq-ruby/SKILL.md +0 -74
  102. data/skills/dhanhq-ruby/references/market_data.md +0 -3
  103. data/skills/dhanhq-ruby/references/orders.md +0 -7
  104. data/watchlist.csv +0 -3
@@ -14,6 +14,10 @@ module DhanHQ
14
14
  # )
15
15
  #
16
16
  class SquareOffPosition < Base
17
+ risk "destructive_write"
18
+ scope "orders:write"
19
+ description "Exit a specific open position by symbol and exchange segment."
20
+
17
21
  param :symbol, type: :string, required: true
18
22
  param :exchange_segment, type: :string, required: true
19
23
 
@@ -23,17 +27,15 @@ module DhanHQ
23
27
  def find_position(ctx)
24
28
  positions = DhanHQ::Models::Position.all
25
29
  target = positions.find do |p|
26
- seg = p[:exchange_segment] || p["exchange_segment"]
27
- sym = p[:trading_symbol] || p["tradingSymbol"] || p[:symbol] || p["symbol"]
28
- seg.to_s == ctx[:exchange_segment].to_s && sym.to_s.upcase == ctx[:symbol].to_s.upcase
30
+ p.exchange_segment.to_s == ctx[:exchange_segment].to_s && p.trading_symbol.to_s.upcase == ctx[:symbol].to_s.upcase
29
31
  end
30
32
 
31
33
  raise ArgumentError, "No open position found for #{ctx[:symbol]} on #{ctx[:exchange_segment]}" unless target
32
34
 
33
35
  ctx[:position] = target
34
- ctx[:security_id] = target[:security_id] || target["securityId"]
35
- ctx[:trading_symbol] = target[:trading_symbol] || target["tradingSymbol"]
36
- ctx[:net_quantity] = (target[:net_quantity] || target["netQuantity"] || target.net_quantity).to_i
36
+ ctx[:security_id] = target.security_id
37
+ ctx[:trading_symbol] = target.trading_symbol
38
+ ctx[:net_quantity] = target.net_qty.to_i
37
39
  ctx
38
40
  end
39
41
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a long straddle (buy ATM call + buy ATM put).
7
+ #
8
+ # Steps: find instrument → spot price → option chain →
9
+ # select ATM strikes → build intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("straddle",
13
+ # symbol: "NIFTY",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 25
16
+ # )
17
+ #
18
+ class Straddle < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a long straddle: buy ATM call + buy ATM put at the same strike."
22
+
23
+ param :symbol, type: :string, required: true
24
+ param :expiry, type: :string, required: true
25
+ param :quantity, type: :integer, default: 25
26
+ param :stop_loss, type: :number, default: 300
27
+ param :target, type: :number, default: 600
28
+
29
+ step :find_instrument, priority: 1
30
+ step :get_spot_price, priority: 2
31
+ step :get_option_chain, priority: 3
32
+ step :select_atm_strikes, priority: 4
33
+ step :build_intent, priority: 5
34
+
35
+ def find_instrument(ctx)
36
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
37
+ ctx
38
+ end
39
+
40
+ def get_spot_price(ctx)
41
+ ctx[:spot_price] = ctx[:instrument].ltp
42
+ ctx
43
+ end
44
+
45
+ def get_option_chain(ctx)
46
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
47
+ ctx
48
+ end
49
+
50
+ def select_atm_strikes(ctx)
51
+ spot = ctx[:spot_price].to_f
52
+ chain = ctx[:chain]
53
+
54
+ atm = nearest_strike(chain, spot)
55
+ raise ArgumentError, "Could not find ATM strike" unless atm
56
+
57
+ ctx[:atm_strike] = atm[:strike]
58
+ ctx[:ce_security_id] = leg_security_id(atm, "CE")
59
+ ctx[:pe_security_id] = leg_security_id(atm, "PE")
60
+ ctx[:ce_premium] = leg_premium(atm, "CE")
61
+ ctx[:pe_premium] = leg_premium(atm, "PE")
62
+ ctx[:total_premium] = ctx[:ce_premium].to_f + ctx[:pe_premium].to_f
63
+ ctx
64
+ end
65
+
66
+ def build_intent(ctx)
67
+ ctx[:intent] = {
68
+ trade_type: "STRADDLE",
69
+ symbol: ctx[:symbol],
70
+ expiry: ctx[:expiry],
71
+ quantity: ctx[:quantity],
72
+ legs: [
73
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: ctx[:atm_strike], security_id: ctx[:ce_security_id], premium: ctx[:ce_premium] },
74
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: ctx[:atm_strike], security_id: ctx[:pe_security_id], premium: ctx[:pe_premium] }
75
+ ],
76
+ total_premium: ctx[:total_premium],
77
+ break_even_upside: ctx[:atm_strike].to_f + ctx[:total_premium],
78
+ break_even_downside: ctx[:atm_strike].to_f - ctx[:total_premium],
79
+ stop_loss: ctx[:stop_loss],
80
+ target: ctx[:target],
81
+ note: "Long straddle prepared. Await human confirmation."
82
+ }
83
+ ctx
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -16,6 +16,10 @@ module DhanHQ
16
16
  # )
17
17
  #
18
18
  class Strangle < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a long strangle: buy OTM call + buy OTM put around the current spot price."
22
+
19
23
  param :symbol, type: :string, required: true
20
24
  param :expiry, type: :string, required: true
21
25
  param :quantity, type: :integer, default: 50
@@ -35,8 +39,7 @@ module DhanHQ
35
39
  end
36
40
 
37
41
  def get_spot_price(ctx)
38
- ltp = ctx[:instrument].ltp
39
- ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
42
+ ctx[:spot_price] = ctx[:instrument].ltp
40
43
  ctx
41
44
  end
42
45
 
@@ -50,24 +53,21 @@ module DhanHQ
50
53
  chain = ctx[:chain]
51
54
  offset = ctx[:offset_pct] / 100.0
52
55
 
53
- ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
54
- pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
55
-
56
56
  ce_strike = spot * (1 + offset)
57
57
  pe_strike = spot * (1 - offset)
58
58
 
59
- long_ce = ce_options.min_by { |o| ((o[:strike] || o["strike"]).to_f - ce_strike).abs }
60
- long_pe = pe_options.min_by { |o| ((o[:strike] || o["strike"]).to_f - pe_strike).abs }
59
+ long_ce = nearest_strike(chain, ce_strike)
60
+ long_pe = nearest_strike(chain, pe_strike)
61
61
 
62
62
  raise ArgumentError, "Could not find suitable CE strike near #{ce_strike}" unless long_ce
63
63
  raise ArgumentError, "Could not find suitable PE strike near #{pe_strike}" unless long_pe
64
64
 
65
- ctx[:ce_strike] = long_ce[:strike] || long_ce["strike"]
66
- ctx[:pe_strike] = long_pe[:strike] || long_pe["strike"]
67
- ctx[:ce_security_id] = long_ce[:security_id] || long_ce["securityId"]
68
- ctx[:pe_security_id] = long_pe[:security_id] || long_pe["securityId"]
69
- ctx[:ce_premium] = long_ce[:last_price] || long_ce["lastPrice"] || long_ce[:ltp]
70
- ctx[:pe_premium] = long_pe[:last_price] || long_pe["lastPrice"] || long_pe[:ltp]
65
+ ctx[:ce_strike] = long_ce[:strike]
66
+ ctx[:pe_strike] = long_pe[:strike]
67
+ ctx[:ce_security_id] = leg_security_id(long_ce, "CE")
68
+ ctx[:pe_security_id] = leg_security_id(long_pe, "PE")
69
+ ctx[:ce_premium] = leg_premium(long_ce, "CE")
70
+ ctx[:pe_premium] = leg_premium(long_pe, "PE")
71
71
  ctx
72
72
  end
73
73
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "3.0.1"
5
+ VERSION = "3.2.0"
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
@@ -53,6 +53,9 @@ module DhanHQ
53
53
  require_relative "DhanHQ/risk/checks/quantity"
54
54
  require_relative "DhanHQ/risk/checks/market_hours"
55
55
  require_relative "DhanHQ/risk/checks/options"
56
+ require_relative "DhanHQ/risk/checks/position_limits"
57
+ require_relative "DhanHQ/risk/checks/concentration"
58
+ require_relative "DhanHQ/risk/checks/max_loss"
56
59
  require_relative "DhanHQ/risk/pipeline"
57
60
 
58
61
  # Skills layer: multi-step trading workflows
@@ -65,6 +68,7 @@ module DhanHQ
65
68
  require_relative "DhanHQ/skills/builtin/square_off_position"
66
69
  require_relative "DhanHQ/skills/builtin/iron_condor"
67
70
  require_relative "DhanHQ/skills/builtin/strangle"
71
+ require_relative "DhanHQ/skills/builtin/market_data_summarizer"
68
72
  DhanHQ::Skills::Registry.load_builtins
69
73
 
70
74
  class Error < StandardError; end
@@ -186,6 +190,49 @@ module DhanHQ
186
190
  configuration
187
191
  end
188
192
 
193
+ # Configures the DhanHQ client by fetching credentials from a dashboard API.
194
+ #
195
+ # @param bearer_token [String] Secret dashboard token (e.g. YOUR_DASHBOARD_TOKEN)
196
+ # @param url [String] The full URL of the dashboard API
197
+ # @return [DhanHQ::Configuration] The configured configuration
198
+ # @raise [DhanHQ::TokenEndpointError] On HTTP error or missing credentials
199
+ def configure_from_dashboard(bearer_token:, url: "http://localhost:3011/api/dhan_access_token")
200
+ raise DhanHQ::TokenEndpointError, "bearer_token is required" if bearer_token.to_s.empty?
201
+
202
+ conn = ::Faraday.new(url: url) do |c|
203
+ c.request :url_encoded
204
+ c.adapter ::Faraday.default_adapter
205
+ end
206
+
207
+ response = conn.get("") do |req|
208
+ req.headers["Authorization"] = "Bearer #{bearer_token}"
209
+ req.headers["Accept"] = "application/json"
210
+ end
211
+
212
+ unless response.success?
213
+ body = parse_json_body(response.body)
214
+ msg = body["error"] || body["message"] || body["errorMessage"] || response.body.to_s
215
+ raise DhanHQ::TokenEndpointError, "Dashboard returned #{response.status}: #{msg}"
216
+ end
217
+
218
+ data = parse_json_body(response.body)
219
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
220
+
221
+ access_token = data["access_token"] || data["accessToken"] || data["dhan_access_token"] || data["dhanaccesstoken"]
222
+ client_id = data["client_id"] || data["clientId"] || data["dhan_client_id"] || data["dhanClientId"]
223
+ client_id ||= self.configuration&.client_id || ENV.fetch("DHAN_CLIENT_ID", nil)
224
+
225
+ raise DhanHQ::TokenEndpointError, "Dashboard response missing access_token (tried access_token, dhan_access_token, dhanaccesstoken)" if access_token.to_s.empty?
226
+ raise DhanHQ::TokenEndpointError, "Dashboard response missing client_id, and no fallback client_id was found in config or ENV['DHAN_CLIENT_ID']" if client_id.to_s.empty?
227
+
228
+ self.configuration ||= Configuration.new
229
+ configuration.access_token = access_token.to_s
230
+ configuration.client_id = client_id.to_s
231
+ dhan_base = data["base_url"] || data["baseUrl"]
232
+ configuration.base_url = dhan_base.to_s if dhan_base.to_s != ""
233
+ configuration
234
+ end
235
+
189
236
  # @param body [String, Hash] Raw response body
190
237
  # @return [Hash] Parsed hash; empty hash on parse failure or empty string
191
238
  def parse_json_body(body)
@@ -100,13 +100,13 @@ module DhanHQ
100
100
  # Choose the nearest expiry (first element); adjust selection if API returns sorted differently
101
101
  expiry = expiries.first
102
102
  raw = DhanHQ::Models::OptionChain.fetch(underlying_scrip: sid.to_i, underlying_seg: seg, expiry: expiry)
103
- oc = raw[:oc] || {}
104
- # Transform OC structure into advisor-friendly array [{ strike:, ce: {...}, pe: {...} }]
105
- @data[:option_chain] = oc.map do |strike, strike_data|
103
+ strikes = raw[:strikes] || []
104
+ # Transform normalized strikes into advisor-friendly array [{ strike:, ce: {...}, pe: {...} }]
105
+ @data[:option_chain] = strikes.map do |entry|
106
106
  {
107
- strike: strike.to_f,
108
- ce: normalize_leg(strike_data["ce"] || {}),
109
- pe: normalize_leg(strike_data["pe"] || {})
107
+ strike: entry[:strike].to_f,
108
+ ce: normalize_leg(entry[:call] || {}),
109
+ pe: normalize_leg(entry[:put] || {})
110
110
  }
111
111
  end
112
112
  rescue StandardError
@@ -114,11 +114,12 @@ module DhanHQ
114
114
  end
115
115
 
116
116
  def normalize_leg(cepe)
117
+ greeks = cepe[:greeks] || {}
117
118
  {
118
- ltp: cepe["last_price"], bid: cepe["best_bid_price"], ask: cepe["best_ask_price"],
119
- iv: cepe["iv"], oi: cepe["oi"], volume: cepe["volume"],
120
- delta: cepe["delta"], gamma: cepe["gamma"], vega: cepe["vega"], theta: cepe["theta"],
121
- lot_size: cepe["lot_size"], tradable: true
119
+ ltp: cepe[:last_price], bid: cepe[:top_bid_price], ask: cepe[:top_ask_price],
120
+ iv: cepe[:implied_volatility], oi: cepe[:oi], volume: cepe[:volume],
121
+ delta: greeks[:delta], gamma: greeks[:gamma], vega: greeks[:vega], theta: greeks[:theta],
122
+ security_id: cepe[:security_id], tradable: true
122
123
  }
123
124
  end
124
125