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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +47 -1
- data/CHANGELOG.md +98 -0
- data/README.md +392 -6
- data/docs/AUTHENTICATION.md +3 -5
- data/docs/CONFIGURATION.md +3 -2
- data/docs/CONSTANTS_REFERENCE.md +8 -6
- data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
- data/docs/LIVE_ORDER_UPDATES.md +5 -10
- data/docs/RAILS_INTEGRATION.md +1 -1
- data/docs/RELEASE_GUIDE.md +13 -2
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
- data/docs/WEBSOCKET_INTEGRATION.md +13 -20
- data/docs/WEBSOCKET_PROTOCOL.md +7 -3
- data/exe/dhanhq-mcp +7 -0
- data/lib/DhanHQ/agent/key_coercion.rb +36 -0
- data/lib/DhanHQ/agent/tool.rb +37 -0
- data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
- data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
- data/lib/DhanHQ/agent/tool_registry.rb +17 -212
- data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
- data/lib/DhanHQ/client.rb +58 -2
- data/lib/DhanHQ/concerns/order_audit.rb +74 -1
- data/lib/DhanHQ/configuration.rb +70 -1
- data/lib/DhanHQ/constants.rb +104 -2
- data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
- data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
- data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
- data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
- data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
- data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
- data/lib/DhanHQ/dry_run/ledger.rb +71 -0
- data/lib/DhanHQ/dry_run/simulator.rb +140 -0
- data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
- data/lib/DhanHQ/mcp/server.rb +172 -9
- data/lib/DhanHQ/models/alert_order.rb +5 -2
- data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
- data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
- data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
- data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
- data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
- data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
- data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
- data/lib/DhanHQ/models/instrument.rb +44 -14
- data/lib/DhanHQ/models/margin.rb +5 -1
- data/lib/DhanHQ/models/multi_order.rb +130 -0
- data/lib/DhanHQ/rate_limiter.rb +5 -3
- data/lib/DhanHQ/resources/alert_orders.rb +1 -0
- data/lib/DhanHQ/resources/forever_orders.rb +1 -0
- data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
- data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
- data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
- data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
- data/lib/DhanHQ/resources/multi_orders.rb +58 -0
- data/lib/DhanHQ/resources/orders.rb +2 -0
- data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
- data/lib/DhanHQ/resources/super_orders.rb +1 -0
- data/lib/DhanHQ/resources/twap_orders.rb +1 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/pipeline.rb +8 -1
- data/lib/DhanHQ/skills/base.rb +54 -3
- data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
- data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
- data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/write_paths.rb +57 -0
- data/lib/DhanHQ/ws/client.rb +117 -2
- data/lib/DhanHQ/ws/connection.rb +40 -11
- data/lib/DhanHQ/ws/sub_state.rb +14 -0
- data/lib/dhan_hq.rb +47 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
- metadata +41 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -48
- data/.rubocop_todo.yml +0 -217
- data/AGENTS.md +0 -23
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -14
- data/TAGS +0 -10
- data/core +0 -0
- data/diagram.html +0 -184
- data/skills/dhanhq-ruby/SKILL.md +0 -74
- data/skills/dhanhq-ruby/references/market_data.md +0 -3
- data/skills/dhanhq-ruby/references/orders.md +0 -7
- data/watchlist.csv +0 -3
|
@@ -2,35 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module DhanHQ
|
|
4
4
|
module Agent
|
|
5
|
-
#
|
|
5
|
+
# Registry and dispatcher for the tools exposed to MCP clients and agent skills.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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 ||=
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,188 +50,6 @@ module DhanHQ
|
|
|
63
50
|
write_enabled: Policy.from_env.writes_enabled?
|
|
64
51
|
}
|
|
65
52
|
end
|
|
66
|
-
|
|
67
|
-
def build_tools
|
|
68
|
-
[
|
|
69
|
-
tool("dhan_profile", "Fetch Dhan profile", "portfolio:read", "read_only",
|
|
70
|
-
object_schema, profile_handler,
|
|
71
|
-
version: "1.0.0",
|
|
72
|
-
output_schema: { type: "object", properties: { client_id: { type: "string" } } }),
|
|
73
|
-
tool("dhan_funds", "Fetch fund limits", "portfolio:read", "read_only",
|
|
74
|
-
object_schema, funds_handler,
|
|
75
|
-
version: "1.0.0",
|
|
76
|
-
output_schema: { type: "object", properties: { available_balance: { type: "number" } } }),
|
|
77
|
-
tool("dhan_holdings", "List holdings", "portfolio:read", "read_only",
|
|
78
|
-
object_schema, holdings_handler,
|
|
79
|
-
version: "1.0.0",
|
|
80
|
-
output_schema: { type: "array", items: { type: "object" } }),
|
|
81
|
-
tool("dhan_positions", "List positions", "portfolio:read", "read_only",
|
|
82
|
-
object_schema, positions_handler,
|
|
83
|
-
version: "1.0.0",
|
|
84
|
-
output_schema: { type: "array", items: { type: "object" } }),
|
|
85
|
-
tool("dhan_orders", "List orders", "orders:read", "read_only",
|
|
86
|
-
object_schema, orders_handler,
|
|
87
|
-
version: "1.0.0",
|
|
88
|
-
output_schema: { type: "array", items: { type: "object" } }),
|
|
89
|
-
tool("dhan_trades", "List trades", "orders:read", "read_only",
|
|
90
|
-
object_schema, trades_handler,
|
|
91
|
-
version: "1.0.0",
|
|
92
|
-
output_schema: { type: "array", items: { type: "object" } }),
|
|
93
|
-
tool("dhan_search_instruments", "Resolve symbols to security IDs", "market:read", "read_only",
|
|
94
|
-
search_schema, search_handler,
|
|
95
|
-
version: "1.0.0",
|
|
96
|
-
output_schema: { type: "array", items: { type: "object" } },
|
|
97
|
-
examples: [
|
|
98
|
-
{ input: { query: "RELIANCE" }, output: "[{security_id: '2885', symbol_name: 'RELIANCE'}]" }
|
|
99
|
-
]),
|
|
100
|
-
tool("dhan_ltp", "Fetch last traded prices", "market:read", "read_only",
|
|
101
|
-
feed_schema, ltp_handler,
|
|
102
|
-
version: "1.0.0",
|
|
103
|
-
output_schema: { type: "object", additionalProperties: { type: "number" } }),
|
|
104
|
-
tool("dhan_quote", "Fetch market quotes", "market:read", "read_only",
|
|
105
|
-
feed_schema, quote_handler,
|
|
106
|
-
version: "1.0.0",
|
|
107
|
-
output_schema: { type: "object", additionalProperties: { type: "object" } }),
|
|
108
|
-
tool("dhan_order_preview", "Validate and summarize an order without placing it", "orders:read",
|
|
109
|
-
"trade_adjacent_read", order_schema, preview_handler,
|
|
110
|
-
version: "1.0.0",
|
|
111
|
-
output_schema: {
|
|
112
|
-
type: "object",
|
|
113
|
-
properties: {
|
|
114
|
-
valid: { type: "boolean" },
|
|
115
|
-
errors: { type: "array" },
|
|
116
|
-
summary: { type: "string" }
|
|
117
|
-
}
|
|
118
|
-
}),
|
|
119
|
-
tool("dhan_place_order", "Place an order after external confirmation", "orders:write", "live_write",
|
|
120
|
-
order_schema, place_order_handler,
|
|
121
|
-
version: "1.0.0",
|
|
122
|
-
output_schema: { type: "object", properties: { order_id: { type: "string" } } }),
|
|
123
|
-
tool("dhan_cancel_order", "Cancel an order", "orders:cancel", "destructive_write",
|
|
124
|
-
cancel_schema, cancel_order_handler,
|
|
125
|
-
version: "1.0.0",
|
|
126
|
-
output_schema: { type: "object", properties: { order_id: { type: "string" }, status: { type: "string" } } })
|
|
127
|
-
].to_h { |tool_item| [tool_item.name, tool_item] }
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# rubocop:disable Metrics/ParameterLists
|
|
131
|
-
def tool(name, description, scope, risk, schema, handler, version: "1.0.0", output_schema: nil, examples: nil)
|
|
132
|
-
Tool.new(
|
|
133
|
-
name: name, description: description, scope: scope, risk: risk,
|
|
134
|
-
schema: schema, handler: handler, version: version,
|
|
135
|
-
output_schema: output_schema, examples: examples
|
|
136
|
-
)
|
|
137
|
-
end
|
|
138
|
-
# rubocop:enable Metrics/ParameterLists
|
|
139
|
-
|
|
140
|
-
def object_schema
|
|
141
|
-
{ type: "object", properties: {}, additionalProperties: false }
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def search_schema
|
|
145
|
-
{
|
|
146
|
-
type: "object",
|
|
147
|
-
required: ["query"],
|
|
148
|
-
properties: {
|
|
149
|
-
query: { type: "string" },
|
|
150
|
-
segments: { type: "array", items: { type: "string" } },
|
|
151
|
-
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
152
|
-
exact_match: { type: "boolean" }
|
|
153
|
-
},
|
|
154
|
-
additionalProperties: false
|
|
155
|
-
}
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def feed_schema
|
|
159
|
-
{
|
|
160
|
-
type: "object",
|
|
161
|
-
required: ["instruments"],
|
|
162
|
-
properties: {
|
|
163
|
-
instruments: {
|
|
164
|
-
type: "object",
|
|
165
|
-
additionalProperties: { type: "array", items: { type: %w[integer string] } }
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
additionalProperties: false
|
|
169
|
-
}
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def order_schema
|
|
173
|
-
{
|
|
174
|
-
type: "object",
|
|
175
|
-
required: %w[transaction_type exchange_segment product_type order_type validity security_id quantity],
|
|
176
|
-
properties: {
|
|
177
|
-
transaction_type: enum(%w[BUY SELL]),
|
|
178
|
-
exchange_segment: { type: "string" },
|
|
179
|
-
product_type: { type: "string" },
|
|
180
|
-
order_type: { type: "string" },
|
|
181
|
-
validity: { type: "string" },
|
|
182
|
-
security_id: { type: "string" },
|
|
183
|
-
quantity: { type: "integer", minimum: 1 },
|
|
184
|
-
price: { type: "number" },
|
|
185
|
-
trigger_price: { type: "number" },
|
|
186
|
-
correlation_id: { type: "string" }
|
|
187
|
-
},
|
|
188
|
-
additionalProperties: true
|
|
189
|
-
}
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def cancel_schema
|
|
193
|
-
{
|
|
194
|
-
type: "object",
|
|
195
|
-
required: ["order_id"],
|
|
196
|
-
properties: { order_id: { type: "string" } },
|
|
197
|
-
additionalProperties: false
|
|
198
|
-
}
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def enum(values)
|
|
202
|
-
{ type: "string", enum: values }
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def profile_handler = ->(_) { DhanHQ::Models::Profile.fetch }
|
|
206
|
-
|
|
207
|
-
def funds_handler = ->(_) { DhanHQ::Models::Funds.fetch }
|
|
208
|
-
|
|
209
|
-
def holdings_handler = ->(_) { DhanHQ::Models::Holding.all }
|
|
210
|
-
|
|
211
|
-
def positions_handler = ->(_) { DhanHQ::Models::Position.all }
|
|
212
|
-
|
|
213
|
-
def orders_handler = ->(_) { DhanHQ::Models::Order.all }
|
|
214
|
-
|
|
215
|
-
def trades_handler = ->(_) { DhanHQ::Models::Trade.today }
|
|
216
|
-
|
|
217
|
-
def search_handler
|
|
218
|
-
lambda do |arguments|
|
|
219
|
-
query = arguments.fetch(:query)
|
|
220
|
-
options = arguments.except(:query)
|
|
221
|
-
DhanHQ::Models::Instrument.search(query, **options)
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def ltp_handler = ->(arguments) { DhanHQ::Models::MarketFeed.ltp(arguments[:instruments]) }
|
|
226
|
-
|
|
227
|
-
def quote_handler = ->(arguments) { DhanHQ::Models::MarketFeed.quote(arguments[:instruments]) }
|
|
228
|
-
|
|
229
|
-
def preview_handler = ->(arguments) { OrderPreview.new(arguments).to_h }
|
|
230
|
-
|
|
231
|
-
def place_order_handler = ->(arguments) { DhanHQ::Models::Order.place(arguments) }
|
|
232
|
-
|
|
233
|
-
def cancel_order_handler
|
|
234
|
-
lambda do |arguments|
|
|
235
|
-
order = DhanHQ::Models::Order.find(arguments[:order_id])
|
|
236
|
-
order&.cancel || false
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def symbolize(value)
|
|
241
|
-
case value
|
|
242
|
-
when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_sym] = symbolize(val) }
|
|
243
|
-
when Array then value.map { |val| symbolize(val) }
|
|
244
|
-
else value
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
53
|
end
|
|
248
|
-
# rubocop:enable Metrics/ModuleLength
|
|
249
54
|
end
|
|
250
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:
|
|
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
|
-
|
|
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
|
|
@@ -4,7 +4,8 @@ require_relative "../utils/network_inspector"
|
|
|
4
4
|
|
|
5
5
|
module DhanHQ
|
|
6
6
|
module Concerns
|
|
7
|
-
# Shared behavior for order audit logging
|
|
7
|
+
# Shared behavior for order audit logging, live trading safety, and
|
|
8
|
+
# pre-trade risk checks.
|
|
8
9
|
#
|
|
9
10
|
# Include this module in any Resource that submits, modifies, or cancels
|
|
10
11
|
# orders on the Dhan API. It provides:
|
|
@@ -17,12 +18,18 @@ module DhanHQ
|
|
|
17
18
|
# unless +ENV["LIVE_TRADING"]+ is +"true"+, preventing accidental order
|
|
18
19
|
# placement from development machines.
|
|
19
20
|
#
|
|
21
|
+
# - {#run_risk_checks!}: runs {DhanHQ::Risk::Pipeline} against the order
|
|
22
|
+
# params before execution. Catches unresolvable instruments and other
|
|
23
|
+
# edge cases silently so the risk layer never blocks a valid order due
|
|
24
|
+
# to a transient lookup failure.
|
|
25
|
+
#
|
|
20
26
|
# @example Including in a resource
|
|
21
27
|
# class MyOrders < BaseAPI
|
|
22
28
|
# include DhanHQ::Concerns::OrderAudit
|
|
23
29
|
#
|
|
24
30
|
# def create(params)
|
|
25
31
|
# ensure_live_trading!
|
|
32
|
+
# run_risk_checks!(params)
|
|
26
33
|
# log_order_context("MY_ORDER_ATTEMPT", params)
|
|
27
34
|
# post("", params: params)
|
|
28
35
|
# end
|
|
@@ -39,6 +46,72 @@ module DhanHQ
|
|
|
39
46
|
"Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement."
|
|
40
47
|
end
|
|
41
48
|
|
|
49
|
+
# Runs the risk pipeline for the given order params.
|
|
50
|
+
#
|
|
51
|
+
# Extracts +security_id+ and +exchange_segment+ from the params,
|
|
52
|
+
# resolves the instrument, and calls {DhanHQ::Risk::Pipeline.run!}.
|
|
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.
|
|
62
|
+
def run_risk_checks!(params)
|
|
63
|
+
security_id = extract_param(params, :securityId, :security_id)
|
|
64
|
+
exchange_segment = extract_param(params, :exchangeSegment, :exchange_segment)
|
|
65
|
+
return unless security_id && exchange_segment
|
|
66
|
+
|
|
67
|
+
instrument = resolve_instrument_for_risk(exchange_segment, security_id)
|
|
68
|
+
return unless instrument
|
|
69
|
+
|
|
70
|
+
DhanHQ::Risk::Pipeline.run!(
|
|
71
|
+
instrument: instrument,
|
|
72
|
+
args: params,
|
|
73
|
+
type: trade_type_for(exchange_segment),
|
|
74
|
+
now: Time.now
|
|
75
|
+
)
|
|
76
|
+
rescue DhanHQ::RiskViolation
|
|
77
|
+
raise
|
|
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)
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
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
|
+
|
|
106
|
+
# Maps an exchange segment string to a pipeline trade type.
|
|
107
|
+
def trade_type_for(exchange_segment)
|
|
108
|
+
case exchange_segment.to_s
|
|
109
|
+
when /^NSE_FNO/, /^BSE_FNO/, /^NSE_CURRENCY/, /^BSE_CURRENCY/ then :fno
|
|
110
|
+
when /^MCX/ then :commodity
|
|
111
|
+
else :equity
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
42
115
|
# Emits a structured JSON log line with machine/network/correlation context.
|
|
43
116
|
# Uses WARN level so it appears even when INFO is silenced.
|
|
44
117
|
def log_order_context(event, params = {})
|