DhanHQ 3.1.0 → 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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +47 -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/client.rb +58 -2
  22. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  23. data/lib/DhanHQ/configuration.rb +70 -1
  24. data/lib/DhanHQ/constants.rb +101 -0
  25. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  26. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  27. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  28. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  29. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  30. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  31. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  32. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  33. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  34. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  35. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  36. data/lib/DhanHQ/models/alert_order.rb +5 -2
  37. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  38. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  39. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  40. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  41. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  42. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  43. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  44. data/lib/DhanHQ/models/margin.rb +5 -1
  45. data/lib/DhanHQ/models/multi_order.rb +130 -0
  46. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  47. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  48. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  49. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  50. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  51. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  52. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  53. data/lib/DhanHQ/version.rb +1 -1
  54. data/lib/DhanHQ/write_paths.rb +57 -0
  55. data/lib/DhanHQ/ws/client.rb +117 -2
  56. data/lib/DhanHQ/ws/connection.rb +40 -11
  57. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  58. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  59. metadata +30 -40
  60. data/.rspec +0 -3
  61. data/.rubocop.yml +0 -50
  62. data/.rubocop_todo.yml +0 -217
  63. data/AGENTS.md +0 -23
  64. data/CODE_OF_CONDUCT.md +0 -132
  65. data/Rakefile +0 -14
  66. data/TAGS +0 -10
  67. data/core +0 -0
  68. data/diagram.html +0 -184
  69. data/skills/dhanhq-ruby/SKILL.md +0 -207
  70. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +0 -54
  71. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +0 -65
  72. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +0 -89
  73. data/skills/dhanhq-ruby/examples/iron_condor.rb +0 -137
  74. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +0 -43
  75. data/skills/dhanhq-ruby/examples/margin_check.rb +0 -42
  76. data/skills/dhanhq-ruby/examples/order_management.rb +0 -105
  77. data/skills/dhanhq-ruby/examples/place_equity_order.rb +0 -36
  78. data/skills/dhanhq-ruby/examples/place_fno_order.rb +0 -76
  79. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +0 -74
  80. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +0 -57
  81. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +0 -65
  82. data/skills/dhanhq-ruby/references/common-workflows.md +0 -76
  83. data/skills/dhanhq-ruby/references/error-codes.md +0 -50
  84. data/skills/dhanhq-ruby/references/funds.md +0 -67
  85. data/skills/dhanhq-ruby/references/instruments.md +0 -85
  86. data/skills/dhanhq-ruby/references/live-feed.md +0 -83
  87. data/skills/dhanhq-ruby/references/market-data.md +0 -119
  88. data/skills/dhanhq-ruby/references/option-chain.md +0 -71
  89. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +0 -76
  90. data/skills/dhanhq-ruby/references/orders.md +0 -201
  91. data/skills/dhanhq-ruby/references/portfolio.md +0 -93
  92. data/skills/dhanhq-ruby/references/scanx-data.md +0 -62
  93. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +0 -323
  94. data/skills/dhanhq-ruby/scripts/resolve_security.rb +0 -168
  95. data/skills/dhanhq-ruby/scripts/trade_logger.rb +0 -131
  96. data/skills/dhanhq-ruby/scripts/validate_order.rb +0 -169
  97. data/watchlist.csv +0 -3
@@ -2,35 +2,17 @@
2
2
 
3
3
  module DhanHQ
4
4
  module Agent
5
- # Machine-readable tool metadata shared by MCP and agent skills.
5
+ # Registry and dispatcher for the tools exposed to MCP clients and agent skills.
6
6
  #
7
- # Each tool has:
8
- # - name, description, scope, risk, schema (input), handler
9
- # - version: semantic version of the tool definition
10
- # - output_schema: JSON Schema for the tool's return value
11
- # - examples: array of example input/output pairs
12
- # rubocop:disable Metrics/ModuleLength
7
+ # Holds the catalogue (which tools exist, at what scope and risk) and enforces
8
+ # {DhanHQ::Agent::Policy} on every call. The tools themselves are described by
9
+ # {DhanHQ::Agent::ToolSchemas} and carried out by {DhanHQ::Agent::ToolHandlers};
10
+ # each tool is a {DhanHQ::Agent::Tool}.
13
11
  module ToolRegistry
14
- Tool = Struct.new(:name, :description, :scope, :risk, :schema, :handler,
15
- :version, :output_schema, :examples) do
16
- def to_h
17
- {
18
- name: name,
19
- description: description,
20
- scope: scope,
21
- risk: risk,
22
- input_schema: schema,
23
- output_schema: output_schema,
24
- version: version,
25
- examples: examples
26
- }.compact
27
- end
28
- end
29
-
30
12
  module_function
31
13
 
32
14
  def tools
33
- @tools ||= build_tools.freeze
15
+ @tools ||= ToolCatalogue.all.freeze
34
16
  end
35
17
 
36
18
  def find(name)
@@ -43,12 +25,17 @@ module DhanHQ
43
25
 
44
26
  def execute(name, arguments = {}, policy: Policy.from_env)
45
27
  tool = find(name)
46
- if tool.risk.to_s.end_with?("write") || tool.risk == "destructive_write"
47
- policy.require_write!(tool.scope)
48
- else
49
- policy.require!(tool.scope)
50
- end
51
- tool.handler.call(symbolize(arguments))
28
+ tool.write? ? policy.require_write!(tool.scope) : policy.require!(tool.scope)
29
+ tool.handler.call(KeyCoercion.symbolize(arguments))
30
+ end
31
+
32
+ # Coerces JSON-delivered argument keys to symbols.
33
+ # Part of this module's surface because {DhanHQ::Agent::OrderPreview} calls it.
34
+ #
35
+ # @param value [Hash, Array, Object]
36
+ # @return [Hash, Array, Object]
37
+ def symbolize(value)
38
+ KeyCoercion.symbolize(value)
52
39
  end
53
40
 
54
41
  # Returns capability manifest for the agent runtime.
@@ -63,237 +50,6 @@ module DhanHQ
63
50
  write_enabled: Policy.from_env.writes_enabled?
64
51
  }
65
52
  end
66
-
67
- def build_tools
68
- (primitive_tools + skill_tools).to_h { |tool_item| [tool_item.name, tool_item] }
69
- end
70
-
71
- def primitive_tools
72
- [
73
- tool("dhan_profile", "Fetch Dhan profile", "portfolio:read", "read_only",
74
- object_schema, profile_handler,
75
- version: "1.0.0",
76
- output_schema: { type: "object", properties: { client_id: { type: "string" } } }),
77
- tool("dhan_funds", "Fetch fund limits", "portfolio:read", "read_only",
78
- object_schema, funds_handler,
79
- version: "1.0.0",
80
- output_schema: { type: "object", properties: { available_balance: { type: "number" } } }),
81
- tool("dhan_holdings", "List holdings", "portfolio:read", "read_only",
82
- object_schema, holdings_handler,
83
- version: "1.0.0",
84
- output_schema: { type: "array", items: { type: "object" } }),
85
- tool("dhan_positions", "List positions", "portfolio:read", "read_only",
86
- object_schema, positions_handler,
87
- version: "1.0.0",
88
- output_schema: { type: "array", items: { type: "object" } }),
89
- tool("dhan_orders", "List orders", "orders:read", "read_only",
90
- object_schema, orders_handler,
91
- version: "1.0.0",
92
- output_schema: { type: "array", items: { type: "object" } }),
93
- tool("dhan_trades", "List trades", "orders:read", "read_only",
94
- object_schema, trades_handler,
95
- version: "1.0.0",
96
- output_schema: { type: "array", items: { type: "object" } }),
97
- tool("dhan_search_instruments", "Resolve symbols to security IDs", "market:read", "read_only",
98
- search_schema, search_handler,
99
- version: "1.0.0",
100
- output_schema: { type: "array", items: { type: "object" } },
101
- examples: [
102
- { input: { query: "RELIANCE" }, output: "[{security_id: '2885', symbol_name: 'RELIANCE'}]" }
103
- ]),
104
- tool("dhan_ltp", "Fetch last traded prices", "market:read", "read_only",
105
- feed_schema, ltp_handler,
106
- version: "1.0.0",
107
- output_schema: { type: "object", additionalProperties: { type: "number" } }),
108
- tool("dhan_quote", "Fetch market quotes", "market:read", "read_only",
109
- feed_schema, quote_handler,
110
- version: "1.0.0",
111
- output_schema: { type: "object", additionalProperties: { type: "object" } }),
112
- tool("dhan_order_preview", "Validate and summarize an order without placing it", "orders:read",
113
- "trade_adjacent_read", order_schema, preview_handler,
114
- version: "1.0.0",
115
- output_schema: {
116
- type: "object",
117
- properties: {
118
- valid: { type: "boolean" },
119
- errors: { type: "array" },
120
- summary: { type: "string" }
121
- }
122
- }),
123
- tool("dhan_place_order", "Place an order after external confirmation", "orders:write", "live_write",
124
- order_schema, place_order_handler,
125
- version: "1.0.0",
126
- output_schema: { type: "object", properties: { order_id: { type: "string" } } }),
127
- tool("dhan_cancel_order", "Cancel an order", "orders:cancel", "destructive_write",
128
- cancel_schema, cancel_order_handler,
129
- version: "1.0.0",
130
- output_schema: { type: "object", properties: { order_id: { type: "string" }, status: { type: "string" } } })
131
- ]
132
- end
133
-
134
- # Exposes each registered DhanHQ::Skills::Registry strategy as an MCP tool,
135
- # gated by the risk/scope the skill class declares (see DhanHQ::Skills::Base).
136
- def skill_tools
137
- DhanHQ::Skills::Registry.list.map do |skill|
138
- klass = DhanHQ::Skills::Registry.find(skill[:name])
139
- tool("dhan_skill_#{skill[:name]}", skill[:description], klass.scope, klass.risk,
140
- skill_input_schema(skill[:params]),
141
- ->(arguments) { DhanHQ::Skills::Registry.call(skill[:name], arguments) },
142
- version: "1.0.0")
143
- end
144
- end
145
-
146
- def skill_input_schema(params)
147
- properties = params.transform_values do |config|
148
- { type: skill_param_type(config[:type]) }.tap { |h| h[:description] = config[:description] if config[:description] }
149
- end
150
- required = params.select { |_, config| config[:required] }.keys.map(&:to_s)
151
- { type: "object", properties: properties, required: required, additionalProperties: false }
152
- end
153
-
154
- def skill_param_type(type)
155
- { string: "string", integer: "integer", number: "number", boolean: "boolean" }.fetch(type.to_sym, "string")
156
- end
157
-
158
- # rubocop:disable Metrics/ParameterLists
159
- def tool(name, description, scope, risk, schema, handler, version: "1.0.0", output_schema: nil, examples: nil)
160
- Tool.new(
161
- name: name, description: description, scope: scope, risk: risk,
162
- schema: schema, handler: handler, version: version,
163
- output_schema: output_schema, examples: examples
164
- )
165
- end
166
- # rubocop:enable Metrics/ParameterLists
167
-
168
- def object_schema
169
- { type: "object", properties: {}, additionalProperties: false }
170
- end
171
-
172
- def search_schema
173
- {
174
- type: "object",
175
- required: ["query"],
176
- properties: {
177
- query: { type: "string" },
178
- segments: { type: "array", items: { type: "string" } },
179
- limit: { type: "integer", minimum: 1, maximum: 100 },
180
- exact_match: { type: "boolean" }
181
- },
182
- additionalProperties: false
183
- }
184
- end
185
-
186
- def feed_schema
187
- {
188
- type: "object",
189
- required: ["instruments"],
190
- properties: {
191
- instruments: {
192
- type: "object",
193
- additionalProperties: { type: "array", items: { type: %w[integer string] } }
194
- }
195
- },
196
- additionalProperties: false
197
- }
198
- end
199
-
200
- def order_schema
201
- {
202
- type: "object",
203
- required: %w[transaction_type exchange_segment product_type order_type validity security_id quantity],
204
- properties: {
205
- transaction_type: enum(%w[BUY SELL]),
206
- exchange_segment: { type: "string" },
207
- product_type: { type: "string" },
208
- order_type: { type: "string" },
209
- validity: { type: "string" },
210
- security_id: { type: "string" },
211
- quantity: { type: "integer", minimum: 1 },
212
- price: { type: "number" },
213
- trigger_price: { type: "number" },
214
- correlation_id: { type: "string" }
215
- },
216
- additionalProperties: true
217
- }
218
- end
219
-
220
- def cancel_schema
221
- {
222
- type: "object",
223
- required: ["order_id"],
224
- properties: { order_id: { type: "string" } },
225
- additionalProperties: false
226
- }
227
- end
228
-
229
- def enum(values)
230
- { type: "string", enum: values }
231
- end
232
-
233
- def profile_handler = ->(_) { DhanHQ::Models::Profile.fetch }
234
-
235
- def funds_handler = ->(_) { DhanHQ::Models::Funds.fetch }
236
-
237
- def holdings_handler = ->(_) { DhanHQ::Models::Holding.all }
238
-
239
- def positions_handler = ->(_) { DhanHQ::Models::Position.all }
240
-
241
- def orders_handler = ->(_) { DhanHQ::Models::Order.all }
242
-
243
- def trades_handler = ->(_) { DhanHQ::Models::Trade.today }
244
-
245
- def search_handler
246
- lambda do |arguments|
247
- query = arguments.fetch(:query)
248
- options = arguments.except(:query)
249
- DhanHQ::Models::Instrument.search(query, **options)
250
- end
251
- end
252
-
253
- def ltp_handler = ->(arguments) { DhanHQ::Models::MarketFeed.ltp(arguments[:instruments]) }
254
-
255
- def quote_handler = ->(arguments) { DhanHQ::Models::MarketFeed.quote(arguments[:instruments]) }
256
-
257
- def preview_handler = ->(arguments) { OrderPreview.new(arguments).to_h }
258
-
259
- def place_order_handler
260
- lambda do |arguments|
261
- instrument = DhanHQ::Models::Instrument.find_by_security_id(arguments[:exchange_segment], arguments[:security_id])
262
- unless instrument
263
- raise DhanHQ::RiskViolation,
264
- "Cannot verify risk for unknown instrument: #{arguments[:exchange_segment]}:#{arguments[:security_id]}"
265
- end
266
-
267
- risk_type = instrument.instrument_type.to_s.start_with?("OPT") ? :options : :equity
268
- DhanHQ::Risk::Pipeline.run!(instrument: instrument, args: stringify(arguments), type: risk_type)
269
-
270
- DhanHQ::Models::Order.place(arguments)
271
- end
272
- end
273
-
274
- def cancel_order_handler
275
- lambda do |arguments|
276
- order = DhanHQ::Models::Order.find(arguments[:order_id])
277
- order&.cancel || false
278
- end
279
- end
280
-
281
- def symbolize(value)
282
- case value
283
- when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_sym] = symbolize(val) }
284
- when Array then value.map { |val| symbolize(val) }
285
- else value
286
- end
287
- end
288
-
289
- def stringify(value)
290
- case value
291
- when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_s] = stringify(val) }
292
- when Array then value.map { |val| stringify(val) }
293
- else value
294
- end
295
- end
296
53
  end
297
- # rubocop:enable Metrics/ModuleLength
298
54
  end
299
55
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Agent
5
+ # JSON Schema fragments describing the input each agent tool accepts.
6
+ #
7
+ # Pure data: no API calls, no policy, no state. Kept apart from
8
+ # {DhanHQ::Agent::ToolRegistry} so that adding an endpoint means editing a schema
9
+ # here and a handler in {DhanHQ::Agent::ToolHandlers}, rather than growing one
10
+ # module that does registration, schema description and dispatch at once.
11
+ module ToolSchemas
12
+ module_function
13
+
14
+ def object_schema
15
+ { type: "object", properties: {}, additionalProperties: false }
16
+ end
17
+
18
+ def search_schema
19
+ {
20
+ type: "object",
21
+ required: ["query"],
22
+ properties: {
23
+ query: { type: "string" },
24
+ segments: { type: "array", items: { type: "string" } },
25
+ limit: { type: "integer", minimum: 1, maximum: 100 },
26
+ exact_match: { type: "boolean" }
27
+ },
28
+ additionalProperties: false
29
+ }
30
+ end
31
+
32
+ def feed_schema
33
+ {
34
+ type: "object",
35
+ required: ["instruments"],
36
+ properties: {
37
+ instruments: {
38
+ type: "object",
39
+ additionalProperties: { type: "array", items: { type: %w[integer string] } }
40
+ }
41
+ },
42
+ additionalProperties: false
43
+ }
44
+ end
45
+
46
+ def order_schema
47
+ {
48
+ type: "object",
49
+ required: %w[transaction_type exchange_segment product_type order_type validity security_id quantity],
50
+ properties: {
51
+ transaction_type: enum(%w[BUY SELL]),
52
+ exchange_segment: { type: "string" },
53
+ product_type: { type: "string" },
54
+ order_type: { type: "string" },
55
+ validity: { type: "string" },
56
+ security_id: { type: "string" },
57
+ quantity: { type: "integer", minimum: 1 },
58
+ price: { type: "number" },
59
+ trigger_price: { type: "number" },
60
+ correlation_id: { type: "string" }
61
+ },
62
+ additionalProperties: true
63
+ }
64
+ end
65
+
66
+ def cancel_schema
67
+ {
68
+ type: "object",
69
+ required: ["order_id"],
70
+ properties: { order_id: { type: "string" } },
71
+ additionalProperties: false
72
+ }
73
+ end
74
+
75
+ # Global Stocks orders carry no exchange segment, product type or validity, and
76
+ # quantity is fractional. AMOUNT orders replace quantity with a dollar amount.
77
+ def global_order_schema
78
+ {
79
+ type: "object",
80
+ required: %w[transaction_type order_type security_id],
81
+ properties: {
82
+ transaction_type: enum(%w[BUY SELL]),
83
+ order_type: enum(DhanHQ::Constants::GlobalStocks::OrderType::ALL),
84
+ security_id: { type: "string" },
85
+ quantity: { type: "number", exclusiveMinimum: 0 },
86
+ price: { type: "number", minimum: 0 },
87
+ trigger_price: { type: "number" },
88
+ stop_loss_price: { type: "number" },
89
+ target_price: { type: "number" },
90
+ amount: { type: "number", description: "Dollar value for AMOUNT orders" },
91
+ correlation_id: { type: "string" }
92
+ },
93
+ additionalProperties: true
94
+ }
95
+ end
96
+
97
+ def global_estimate_schema
98
+ {
99
+ type: "object",
100
+ required: %w[security_id transaction_type price quantity],
101
+ properties: {
102
+ security_id: { type: "string" },
103
+ transaction_type: enum(%w[BUY SELL]),
104
+ price: { type: "number", minimum: 0 },
105
+ quantity: { type: "number", exclusiveMinimum: 0 }
106
+ },
107
+ additionalProperties: false
108
+ }
109
+ end
110
+
111
+ def multi_order_schema
112
+ {
113
+ type: "object",
114
+ required: ["orders"],
115
+ properties: {
116
+ orders: {
117
+ type: "array",
118
+ minItems: 1,
119
+ maxItems: DhanHQ::Contracts::MultiOrderContract::MAX_ORDERS,
120
+ items: {
121
+ type: "object",
122
+ required: %w[sequence transaction_type exchange_segment],
123
+ properties: {
124
+ sequence: { type: "string" },
125
+ transaction_type: enum(%w[BUY SELL]),
126
+ exchange_segment: { type: "string" },
127
+ product_type: { type: "string" },
128
+ order_type: { type: "string" },
129
+ validity: { type: "string" },
130
+ security_id: { type: "string" },
131
+ quantity: { type: "integer", minimum: 1 },
132
+ price: { type: "number" },
133
+ trigger_price: { type: "number" }
134
+ },
135
+ additionalProperties: true
136
+ }
137
+ }
138
+ },
139
+ additionalProperties: false
140
+ }
141
+ end
142
+
143
+ def enum(values)
144
+ { type: "string", enum: values }
145
+ end
146
+
147
+ def skill_input_schema(params)
148
+ properties = params.transform_values do |config|
149
+ { type: skill_param_type(config[:type]) }.tap { |h| h[:description] = config[:description] if config[:description] }
150
+ end
151
+ required = params.select { |_, config| config[:required] }.keys.map(&:to_s)
152
+ { type: "object", properties: properties, required: required, additionalProperties: false }
153
+ end
154
+
155
+ def skill_param_type(type)
156
+ { string: "string", integer: "integer", number: "number", boolean: "boolean" }.fetch(type.to_sym, "string")
157
+ end
158
+ end
159
+ end
160
+ end
data/lib/DhanHQ/client.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "securerandom"
5
6
  require "active_support/core_ext/hash/indifferent_access"
6
7
  require_relative "errors"
7
8
  require_relative "rate_limiter"
@@ -60,12 +61,19 @@ module DhanHQ
60
61
  # @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] Parsed JSON response.
61
62
  # @raise [DhanHQ::Error] If an HTTP error occurs.
62
63
  def request(method, path, payload, retries: 3)
64
+ payload = with_correlation_id(method, path, payload)
65
+ return dry_run.response_for(method, path, payload) if dry_run.simulates?(method, path)
66
+
63
67
  @token_manager&.ensure_valid_token!
64
68
  @rate_limiter.throttle!
65
69
  refresh_connection!
66
70
 
71
+ # A non-idempotent write is not safely retryable: the API has no idempotency
72
+ # key, so a request that timed out may already have reached the exchange.
73
+ effective_retries = retryable_write?(method, path) ? retries : 0
74
+
67
75
  with_auth_retry do
68
- with_transient_retry(retries: retries) do
76
+ with_transient_retry(retries: effective_retries) do
69
77
  response = connection.send(method, path) do |req|
70
78
  req.headers.merge!(build_headers(path))
71
79
  prepare_payload(req, payload, method, path)
@@ -103,7 +111,11 @@ module DhanHQ
103
111
  retry
104
112
  rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
105
113
  attempt += 1
106
- raise DhanHQ::NetworkError, "Request failed after #{retries} retries: #{e.message}" if attempt > retries
114
+ if attempt > retries
115
+ raise DhanHQ::NetworkError, "Request failed: #{e.message}" if retries.zero?
116
+
117
+ raise DhanHQ::NetworkError, "Request failed after #{retries} retries: #{e.message}"
118
+ end
107
119
 
108
120
  backoff = calculate_backoff(attempt)
109
121
  DhanHQ.logger&.warn(
@@ -197,6 +209,50 @@ module DhanHQ
197
209
 
198
210
  private
199
211
 
212
+ # Simulator that answers state-changing requests locally when dry run is on.
213
+ #
214
+ # @return [DhanHQ::DryRun::Simulator]
215
+ def dry_run
216
+ @dry_run ||= DhanHQ::DryRun::Simulator.new
217
+ end
218
+
219
+ # A write may be auto-retried only when the user has explicitly opted in.
220
+ def retryable_write?(method, path)
221
+ return true unless WritePaths.mutating?(method, path)
222
+
223
+ DhanHQ.configuration&.retry_non_idempotent_writes? || false
224
+ end
225
+
226
+ # Fills in a +correlationId+ on order placements that lack one, so a caller who
227
+ # hits a timeout can still discover whether the order reached the exchange via
228
+ # GET /v2/orders/external/{correlation-id}.
229
+ def with_correlation_id(method, path, payload)
230
+ return payload unless WritePaths.order_placement?(method, path)
231
+ return payload unless payload.is_a?(Hash)
232
+ return payload unless DhanHQ.configuration&.auto_correlation_id?
233
+ return payload if correlation_id?(payload)
234
+
235
+ inject_correlation_id(payload)
236
+ end
237
+
238
+ # Keys a caller may already have used for the correlation id, in either casing.
239
+ CORRELATION_ID_KEYS = %i[correlationId correlation_id].flat_map { |key| [key, key.to_s] }.freeze
240
+ private_constant :CORRELATION_ID_KEYS
241
+
242
+ # @return [Boolean] True when the caller supplied their own correlation id.
243
+ def correlation_id?(payload)
244
+ CORRELATION_ID_KEYS.any? { |key| payload.key?(key) }
245
+ end
246
+
247
+ # Returns a copy of the payload carrying a generated correlation id, matching the
248
+ # key type the payload already uses.
249
+ def inject_correlation_id(payload)
250
+ correlation_id = "dhq-#{SecureRandom.hex(8)}"
251
+ out = payload.dup
252
+ out.keys.any?(String) ? out["correlationId"] = correlation_id : out[:correlationId] = correlation_id
253
+ out
254
+ end
255
+
200
256
  def refresh_connection!
201
257
  current_url = DhanHQ.configuration.base_url
202
258
  return if @last_base_url == current_url
@@ -50,14 +50,21 @@ module DhanHQ
50
50
  #
51
51
  # Extracts +security_id+ and +exchange_segment+ from the params,
52
52
  # resolves the instrument, and calls {DhanHQ::Risk::Pipeline.run!}.
53
- # If the instrument cannot be resolved the check is skipped silently
54
- # rather than blocking the order.
53
+ # If the instrument cannot be resolved the check is skipped rather than
54
+ # blocking the order, since a transient scrip-master lookup failure should not
55
+ # stop a valid trade.
56
+ #
57
+ # Anything other than a lookup failure is logged before being swallowed. This
58
+ # method used to rescue StandardError silently, which is how a wrong-arity call
59
+ # to `Instrument.find` went unnoticed long enough for the risk pipeline to never
60
+ # run for any real order (see CHANGELOG 3.1.0). A skipped check is tolerable; a
61
+ # skipped check nobody can see is not.
55
62
  def run_risk_checks!(params)
56
63
  security_id = extract_param(params, :securityId, :security_id)
57
64
  exchange_segment = extract_param(params, :exchangeSegment, :exchange_segment)
58
65
  return unless security_id && exchange_segment
59
66
 
60
- instrument = DhanHQ::Models::Instrument.find_by_security_id(exchange_segment, security_id)
67
+ instrument = resolve_instrument_for_risk(exchange_segment, security_id)
61
68
  return unless instrument
62
69
 
63
70
  DhanHQ::Risk::Pipeline.run!(
@@ -68,10 +75,34 @@ module DhanHQ
68
75
  )
69
76
  rescue DhanHQ::RiskViolation
70
77
  raise
71
- rescue StandardError
78
+ rescue StandardError => e
79
+ log_risk_check_skipped(e, security_id, exchange_segment)
80
+ nil
81
+ end
82
+
83
+ # Resolves the instrument the risk pipeline needs, tolerating a lookup failure.
84
+ #
85
+ # @return [DhanHQ::Models::Instrument, nil]
86
+ def resolve_instrument_for_risk(exchange_segment, security_id)
87
+ DhanHQ::Models::Instrument.find_by_security_id(exchange_segment, security_id)
88
+ rescue StandardError => e
89
+ log_risk_check_skipped(e, security_id, exchange_segment)
72
90
  nil
73
91
  end
74
92
 
93
+ # Records that pre-trade risk checks did not run, and why.
94
+ def log_risk_check_skipped(error, security_id, exchange_segment)
95
+ DhanHQ.logger&.error(
96
+ JSON.generate(
97
+ event: "DHAN_RISK_CHECK_SKIPPED",
98
+ security_id: security_id,
99
+ exchange_segment: exchange_segment,
100
+ error: error.class.name,
101
+ message: error.message
102
+ )
103
+ )
104
+ end
105
+
75
106
  # Maps an exchange segment string to a pipeline trade type.
76
107
  def trade_type_for(exchange_segment)
77
108
  case exchange_segment.to_s