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
@@ -317,12 +317,10 @@ end
317
317
  client = DhanHQ::WS::MarketDepth.client
318
318
 
319
319
  # Event handlers
320
+ # Note: the current implementation only ever emits :depth_update (never :depth_snapshot),
321
+ # and update_data has no :side key — inspect update_data[:bids]/[:asks] directly instead.
320
322
  client.on(:depth_update) do |update_data|
321
- puts "📊 Depth Update: #{update_data[:symbol]} - #{update_data[:side]} side updated"
322
- end
323
-
324
- client.on(:depth_snapshot) do |snapshot_data|
325
- puts "📸 Depth Snapshot: #{snapshot_data[:symbol]} - Full order book received"
323
+ puts "📊 Depth Update: #{update_data[:symbol]} - bids/asks refreshed"
326
324
  end
327
325
 
328
326
  client.on(:error) do |error|
@@ -771,8 +769,7 @@ puts "Orders connected: #{orders_client.connected?}"
771
769
  puts "Market connected: #{market_client.connected?}"
772
770
  puts "Depth connected: #{depth_client.connected?}"
773
771
 
774
- # Get subscription info
775
- puts "Market subscriptions: #{market_client.subscriptions}"
772
+ # Get subscription info (only DhanHQ::WS::MarketDepth::Client exposes this)
776
773
  puts "Depth subscriptions: #{depth_client.subscriptions}"
777
774
  ```
778
775
 
@@ -890,30 +887,26 @@ puts "Orders connected: #{orders_client.connected?}"
890
887
  puts "Market connected: #{market_client.connected?}"
891
888
  puts "Depth connected: #{depth_client.connected?}"
892
889
 
893
- # Get subscription info
894
- puts "Market subscriptions: #{market_client.subscriptions}"
890
+ # Get subscription info (only DhanHQ::WS::MarketDepth::Client exposes this)
895
891
  puts "Depth subscriptions: #{depth_client.subscriptions}"
896
892
  ```
897
893
 
898
894
  ## Testing
899
895
 
900
- For comprehensive testing examples and interactive console helpers, see the [Testing Guide](TESTING_GUIDE.md). The guide includes:
901
-
902
- - **WebSocket Testing**: Market feed, order updates, and market depth examples
903
- - **Interactive Console Helpers**: Load `bin/test_helpers.rb` for quick test functions
904
- - **Complete Examples**: Copy-paste examples for all WebSocket features
896
+ For comprehensive testing examples, see the [Testing Guide](TESTING_GUIDE.md).
905
897
 
906
898
  **Quick start in console:**
907
899
  ```ruby
908
900
  # Start console
909
901
  bin/console
910
902
 
911
- # Load test helpers
912
- load 'bin/test_helpers.rb'
913
-
914
- # Test WebSocket connections
915
- test_websocket(:ticker, 5) # Test market feed for 5 seconds
916
- test_order_websocket(5) # Test order updates for 5 seconds
903
+ # Test market feed for 5 seconds
904
+ ticks = []
905
+ client = DhanHQ::WS.connect(mode: :ticker) { |tick| ticks << tick }
906
+ client.subscribe_one(segment: "IDX_I", security_id: 13)
907
+ sleep 5
908
+ client.disconnect!
909
+ p ticks
917
910
  ```
918
911
 
919
912
  ## Examples
@@ -16,14 +16,16 @@ Low-level protocol details for the DhanHQ WebSocket market feed. For high-level
16
16
 
17
17
  ## Request Codes
18
18
 
19
- Per Dhan documentation:
19
+ Per the actual implementation (`lib/DhanHQ/ws/connection.rb`):
20
20
 
21
21
  | Action | Ticker | Quote | Full |
22
22
  | ------------ | ------ | ----- | ---- |
23
- | Subscribe | 15 | 17 | 21 |
24
- | Unsubscribe | 16 | 18 | 22 |
23
+ | Subscribe | 15 | 15 | 15 |
24
+ | Unsubscribe | 12 | 12 | 12 |
25
25
  | Disconnect | 12 | 12 | 12 |
26
26
 
27
+ All three modes send request code `15` to subscribe (per the official API — mode is set at connection time via the WebSocket URL, not per-message). "Unsubscribe" reuses the disconnect code `12` for all modes rather than distinct per-mode codes.
28
+
27
29
  ---
28
30
 
29
31
  ## Packet Parsing
@@ -48,7 +50,9 @@ Per Dhan documentation:
48
50
  | 6 | Prev Close | `prev_close`, `oi_prev` |
49
51
  | 7 | Market Status | Raw/misc unless documented |
50
52
  | 8 | Full | Quote fields + `open_interest` + 5× depth (bid/ask) |
53
+ | 41 | Depth Bid | Market depth bid levels (20-level depth feed) |
51
54
  | 50 | Disconnect | Reason code |
55
+ | 51 | Depth Ask | Market depth ask levels (20-level depth feed) |
52
56
 
53
57
  ---
54
58
 
data/exe/dhanhq-mcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dhan_hq"
5
+ require "dhan_hq/mcp"
6
+
7
+ DhanHQ::MCP::Server.new.run
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Agent
5
+ # Recursive key coercion for arguments crossing the agent boundary.
6
+ #
7
+ # MCP delivers arguments as JSON, so keys arrive as strings; handlers and the risk
8
+ # pipeline expect symbols and strings respectively.
9
+ #
10
+ # Called explicitly (`KeyCoercion.symbolize(...)`) rather than mixed in, so there is
11
+ # no question about method visibility at the call site.
12
+ module KeyCoercion
13
+ module_function
14
+
15
+ # @param value [Hash, Array, Object]
16
+ # @return [Hash, Array, Object] Same shape with symbol keys throughout.
17
+ def symbolize(value)
18
+ case value
19
+ when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_sym] = symbolize(val) }
20
+ when Array then value.map { |val| symbolize(val) }
21
+ else value
22
+ end
23
+ end
24
+
25
+ # @param value [Hash, Array, Object]
26
+ # @return [Hash, Array, Object] Same shape with string keys throughout.
27
+ def stringify(value)
28
+ case value
29
+ when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_s] = stringify(val) }
30
+ when Array then value.map { |val| stringify(val) }
31
+ else value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Agent
5
+ # A single tool exposed to MCP clients and agent skills.
6
+ #
7
+ # - +name+, +description+ — what a client sees in `tools/list`
8
+ # - +scope+ — the {DhanHQ::Agent::Policy} scope required to call it
9
+ # - +risk+ — +read_only+, +trade_adjacent_read+, +live_write+ or +destructive_write+;
10
+ # anything ending in +write+ goes through `Policy#require_write!`
11
+ # - +schema+, +output_schema+ — JSON Schema for input and return value
12
+ # - +handler+ — callable invoked with symbolized arguments
13
+ # - +version+ — semantic version of this tool definition
14
+ # - +examples+ — optional input/output pairs shown to clients
15
+ Tool = Struct.new(:name, :description, :scope, :risk, :schema, :handler,
16
+ :version, :output_schema, :examples) do
17
+ # @return [Hash] Client-facing metadata, omitting unset optional fields.
18
+ def to_h
19
+ {
20
+ name: name,
21
+ description: description,
22
+ scope: scope,
23
+ risk: risk,
24
+ input_schema: schema,
25
+ output_schema: output_schema,
26
+ version: version,
27
+ examples: examples
28
+ }.compact
29
+ end
30
+
31
+ # @return [Boolean] True when calling this tool changes account state.
32
+ def write?
33
+ risk.to_s.end_with?("write")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Agent
5
+ # The catalogue of tools exposed to MCP clients and agent skills: what exists, at
6
+ # what scope, at what risk, and which handler carries it out.
7
+ #
8
+ # Declarative on purpose — adding an endpoint means adding an entry here, a schema
9
+ # in {DhanHQ::Agent::ToolSchemas} and a handler in {DhanHQ::Agent::ToolHandlers}.
10
+ # {DhanHQ::Agent::ToolRegistry} consumes this and is left responsible only for
11
+ # lookup, policy enforcement and dispatch.
12
+ module ToolCatalogue
13
+ extend ToolSchemas
14
+ extend ToolHandlers
15
+
16
+ # Version stamped on a tool definition that does not declare its own.
17
+ DEFAULT_TOOL_VERSION = "1.0.0"
18
+
19
+ module_function
20
+
21
+ # Every tool, keyed by name.
22
+ #
23
+ # @return [Hash{String => DhanHQ::Agent::Tool}]
24
+ def all
25
+ build_tools
26
+ end
27
+
28
+ def build_tools
29
+ (primitive_tools + global_stocks_tools + skill_tools).to_h { |tool_item| [tool_item.name, tool_item] }
30
+ end
31
+
32
+ def primitive_tools
33
+ [
34
+ tool(name: "dhan_profile", description: "Fetch Dhan profile",
35
+ scope: "portfolio:read", risk: "read_only",
36
+ schema: object_schema, handler: profile_handler,
37
+ output_schema: { type: "object", properties: { client_id: { type: "string" } } }),
38
+ tool(name: "dhan_funds", description: "Fetch fund limits",
39
+ scope: "portfolio:read", risk: "read_only",
40
+ schema: object_schema, handler: funds_handler,
41
+ output_schema: { type: "object", properties: { available_balance: { type: "number" } } }),
42
+ tool(name: "dhan_holdings", description: "List holdings",
43
+ scope: "portfolio:read", risk: "read_only",
44
+ schema: object_schema, handler: holdings_handler,
45
+ output_schema: { type: "array", items: { type: "object" } }),
46
+ tool(name: "dhan_positions", description: "List positions",
47
+ scope: "portfolio:read", risk: "read_only",
48
+ schema: object_schema, handler: positions_handler,
49
+ output_schema: { type: "array", items: { type: "object" } }),
50
+ tool(name: "dhan_orders", description: "List orders",
51
+ scope: "orders:read", risk: "read_only",
52
+ schema: object_schema, handler: orders_handler,
53
+ output_schema: { type: "array", items: { type: "object" } }),
54
+ tool(name: "dhan_trades", description: "List trades",
55
+ scope: "orders:read", risk: "read_only",
56
+ schema: object_schema, handler: trades_handler,
57
+ output_schema: { type: "array", items: { type: "object" } }),
58
+ tool(name: "dhan_search_instruments", description: "Resolve symbols to security IDs",
59
+ scope: "market:read", risk: "read_only",
60
+ schema: search_schema, handler: search_handler,
61
+ output_schema: { type: "array", items: { type: "object" } },
62
+ examples: [
63
+ { input: { query: "RELIANCE" }, output: "[{security_id: '2885', symbol_name: 'RELIANCE'}]" }
64
+ ]),
65
+ tool(name: "dhan_ltp", description: "Fetch last traded prices",
66
+ scope: "market:read", risk: "read_only",
67
+ schema: feed_schema, handler: ltp_handler,
68
+ output_schema: { type: "object", additionalProperties: { type: "number" } }),
69
+ tool(name: "dhan_quote", description: "Fetch market quotes",
70
+ scope: "market:read", risk: "read_only",
71
+ schema: feed_schema, handler: quote_handler,
72
+ output_schema: { type: "object", additionalProperties: { type: "object" } }),
73
+ tool(name: "dhan_order_preview", description: "Validate and summarize an order without placing it",
74
+ scope: "orders:read", risk: "trade_adjacent_read",
75
+ schema: order_schema, handler: preview_handler,
76
+ output_schema: {
77
+ type: "object",
78
+ properties: {
79
+ valid: { type: "boolean" },
80
+ errors: { type: "array" },
81
+ summary: { type: "string" }
82
+ }
83
+ }),
84
+ tool(name: "dhan_place_order", description: "Place an order after external confirmation",
85
+ scope: "orders:write", risk: "live_write",
86
+ schema: order_schema, handler: place_order_handler,
87
+ output_schema: { type: "object", properties: { order_id: { type: "string" } } }),
88
+ tool(name: "dhan_cancel_order", description: "Cancel an order",
89
+ scope: "orders:cancel", risk: "destructive_write",
90
+ schema: cancel_schema, handler: cancel_order_handler,
91
+ output_schema: {
92
+ type: "object",
93
+ properties: { order_id: { type: "string" }, status: { type: "string" } }
94
+ })
95
+ ]
96
+ end
97
+
98
+ # Tools for the Global Stocks (US equities) book, plus basket orders.
99
+ #
100
+ # Global Stocks are a separate book from domestic NSE/BSE trading, so they get
101
+ # their own tools rather than extra flags on the domestic ones — an agent asked
102
+ # for "my holdings" should not silently mix INR and USD positions.
103
+ def global_stocks_tools
104
+ [
105
+ tool(name: "dhan_global_holdings", description: "List US stock holdings",
106
+ scope: "portfolio:read", risk: "read_only",
107
+ schema: object_schema, handler: global_holdings_handler,
108
+ output_schema: { type: "array", items: { type: "object" } }),
109
+ tool(name: "dhan_global_funds", description: "Fetch US (USD) fund limits",
110
+ scope: "portfolio:read", risk: "read_only",
111
+ schema: object_schema, handler: global_funds_handler,
112
+ output_schema: { type: "object", properties: { available_cash: { type: "number" } } }),
113
+ tool(name: "dhan_global_orders", description: "List US stock orders",
114
+ scope: "orders:read", risk: "read_only",
115
+ schema: object_schema, handler: global_orders_handler,
116
+ output_schema: { type: "array", items: { type: "object" } }),
117
+ tool(name: "dhan_global_trades", description: "List US stock trades",
118
+ scope: "orders:read", risk: "read_only",
119
+ schema: object_schema, handler: global_trades_handler,
120
+ output_schema: { type: "array", items: { type: "object" } }),
121
+ tool(name: "dhan_global_market_status", description: "Check whether the US market is open",
122
+ scope: "market:read", risk: "read_only",
123
+ schema: object_schema, handler: global_market_status_handler,
124
+ output_schema: {
125
+ type: "object",
126
+ properties: { status: { type: "string" }, open: { type: "boolean" } }
127
+ }),
128
+ tool(name: "dhan_global_order_estimate",
129
+ description: "Estimate charges and margin for a US stock order without placing it",
130
+ scope: "orders:read", risk: "trade_adjacent_read",
131
+ schema: global_estimate_schema, handler: global_estimate_handler,
132
+ output_schema: {
133
+ type: "object",
134
+ properties: { total_charges: { type: "number" }, total_margin: { type: "number" } }
135
+ }),
136
+ tool(name: "dhan_global_place_order", description: "Place a US stock order after external confirmation",
137
+ scope: "orders:write", risk: "live_write",
138
+ schema: global_order_schema, handler: global_place_order_handler,
139
+ output_schema: { type: "object", properties: { order_id: { type: "string" } } }),
140
+ tool(name: "dhan_global_cancel_order", description: "Cancel a US stock order",
141
+ scope: "orders:cancel", risk: "destructive_write",
142
+ schema: cancel_schema, handler: global_cancel_order_handler,
143
+ output_schema: { type: "object", properties: { order_id: { type: "string" } } }),
144
+ tool(name: "dhan_multi_order", description: "Place a basket of up to 15 domestic orders in one request",
145
+ scope: "orders:write", risk: "live_write",
146
+ schema: multi_order_schema, handler: multi_order_handler,
147
+ output_schema: {
148
+ type: "object",
149
+ properties: { orders: { type: "array", items: { type: "object" } } }
150
+ })
151
+ ]
152
+ end
153
+
154
+ # Exposes each registered DhanHQ::Skills::Registry strategy as an MCP tool,
155
+ # gated by the risk/scope the skill class declares (see DhanHQ::Skills::Base).
156
+ def skill_tools
157
+ DhanHQ::Skills::Registry.list.map do |skill|
158
+ klass = DhanHQ::Skills::Registry.find(skill[:name])
159
+ tool(name: "dhan_skill_#{skill[:name]}", description: skill[:description],
160
+ scope: klass.scope, risk: klass.risk,
161
+ schema: skill_input_schema(skill[:params]),
162
+ handler: ->(arguments) { DhanHQ::Skills::Registry.call(skill[:name], arguments) })
163
+ end
164
+ end
165
+
166
+ # Builds a {DhanHQ::Agent::Tool}.
167
+ #
168
+ # Keyword-only: with nine attributes, positional order was a memory test, and a
169
+ # transposed scope and risk would have produced a silently mis-gated tool.
170
+ # An unknown attribute raises, so a typo cannot create a half-built tool.
171
+ #
172
+ # @param attributes [Hash] Any {DhanHQ::Agent::Tool} member. +version+ defaults
173
+ # to {DEFAULT_TOOL_VERSION}.
174
+ # @return [DhanHQ::Agent::Tool]
175
+ def tool(**attributes)
176
+ Tool.new(version: DEFAULT_TOOL_VERSION, **attributes)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Agent
5
+ # Callables that carry out each agent tool, one per tool.
6
+ #
7
+ # Every handler takes a hash of symbolized arguments and returns a model, a plain
8
+ # hash, or a boolean. Policy enforcement happens before a handler is reached — see
9
+ # {DhanHQ::Agent::ToolRegistry#execute} — so handlers here contain no scope or
10
+ # live-trading checks of their own; the resources they call apply the
11
+ # `LIVE_TRADING` gate and audit logging.
12
+ module ToolHandlers
13
+ module_function
14
+
15
+ def profile_handler = ->(_) { DhanHQ::Models::Profile.fetch }
16
+
17
+ def funds_handler = ->(_) { DhanHQ::Models::Funds.fetch }
18
+
19
+ def holdings_handler = ->(_) { DhanHQ::Models::Holding.all }
20
+
21
+ def positions_handler = ->(_) { DhanHQ::Models::Position.all }
22
+
23
+ def orders_handler = ->(_) { DhanHQ::Models::Order.all }
24
+
25
+ def trades_handler = ->(_) { DhanHQ::Models::Trade.today }
26
+
27
+ def search_handler
28
+ lambda do |arguments|
29
+ query = arguments.fetch(:query)
30
+ options = arguments.except(:query)
31
+ DhanHQ::Models::Instrument.search(query, **options)
32
+ end
33
+ end
34
+
35
+ def ltp_handler = ->(arguments) { DhanHQ::Models::MarketFeed.ltp(arguments[:instruments]) }
36
+
37
+ def quote_handler = ->(arguments) { DhanHQ::Models::MarketFeed.quote(arguments[:instruments]) }
38
+
39
+ def preview_handler = ->(arguments) { OrderPreview.new(arguments).to_h }
40
+
41
+ def place_order_handler
42
+ lambda do |arguments|
43
+ instrument = DhanHQ::Models::Instrument.find_by_security_id(arguments[:exchange_segment], arguments[:security_id])
44
+ unless instrument
45
+ raise DhanHQ::RiskViolation,
46
+ "Cannot verify risk for unknown instrument: #{arguments[:exchange_segment]}:#{arguments[:security_id]}"
47
+ end
48
+
49
+ risk_type = instrument.instrument_type.to_s.start_with?("OPT") ? :options : :equity
50
+ DhanHQ::Risk::Pipeline.run!(instrument: instrument, args: KeyCoercion.stringify(arguments), type: risk_type)
51
+
52
+ DhanHQ::Models::Order.place(arguments)
53
+ end
54
+ end
55
+
56
+ def cancel_order_handler
57
+ lambda do |arguments|
58
+ order = DhanHQ::Models::Order.find(arguments[:order_id])
59
+ order&.cancel || false
60
+ end
61
+ end
62
+
63
+ def global_holdings_handler = ->(_) { DhanHQ::Models::GlobalStocks::Holding.all }
64
+
65
+ def global_funds_handler = ->(_) { DhanHQ::Models::GlobalStocks::Funds.fetch }
66
+
67
+ def global_orders_handler = ->(_) { DhanHQ::Models::GlobalStocks::Order.all }
68
+
69
+ def global_trades_handler = ->(_) { DhanHQ::Models::GlobalStocks::Trade.all }
70
+
71
+ def global_market_status_handler
72
+ lambda do |_|
73
+ status = DhanHQ::Models::GlobalStocks::MarketStatus.fetch
74
+ {
75
+ status: status.status,
76
+ open: status.open?,
77
+ holiday: status.holiday?,
78
+ market_open_time: status.market_open_time,
79
+ market_close_time: status.market_close_time
80
+ }
81
+ end
82
+ end
83
+
84
+ # Combines the charge estimate and the margin requirement so an agent can decide
85
+ # affordability in one call rather than two.
86
+ def global_estimate_handler
87
+ lambda do |arguments|
88
+ estimate = DhanHQ::Models::GlobalStocks::OrderEstimate.calculate(arguments)
89
+ margin = DhanHQ::Models::GlobalStocks::Margin.calculate(arguments)
90
+ {
91
+ total_charges: estimate.total_charges,
92
+ brokerage: estimate.brokerage,
93
+ total_margin: margin.total_margin,
94
+ available_balance: margin.available_bal,
95
+ sufficient: margin.sufficient?
96
+ }
97
+ end
98
+ end
99
+
100
+ # No {DhanHQ::Risk::Pipeline} run here: its checks resolve instruments from the
101
+ # Indian scrip master and encode NSE/BSE rules, neither of which applies to US
102
+ # equities. The LIVE_TRADING gate and audit logging in the resource still apply,
103
+ # as does {Policy#require_write!}.
104
+ def global_place_order_handler = ->(arguments) { DhanHQ::Models::GlobalStocks::Order.place(arguments) }
105
+
106
+ def global_cancel_order_handler
107
+ lambda do |arguments|
108
+ order_id = arguments[:order_id]
109
+ { order_id: order_id, cancelled: DhanHQ::Models::GlobalStocks::Order.cancel(order_id) }
110
+ end
111
+ end
112
+
113
+ def multi_order_handler = ->(arguments) { DhanHQ::Models::MultiOrder.place(arguments[:orders]) }
114
+ end
115
+ end
116
+ end