DhanHQ 3.0.0 → 3.1.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +51 -0
  5. data/README.md +152 -4
  6. data/docs/CONSTANTS_REFERENCE.md +3 -2
  7. data/exe/dhanhq-mcp +7 -0
  8. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  9. data/lib/DhanHQ/agent/policy.rb +51 -0
  10. data/lib/DhanHQ/agent/tool_registry.rb +299 -0
  11. data/lib/DhanHQ/agent.rb +12 -0
  12. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  13. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  14. data/lib/DhanHQ/ai.rb +27 -0
  15. data/lib/DhanHQ/auth.rb +0 -1
  16. data/lib/DhanHQ/client.rb +1 -3
  17. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  18. data/lib/DhanHQ/constants.rb +5 -2
  19. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  20. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  21. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  22. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  23. data/lib/DhanHQ/core/auth_api.rb +0 -1
  24. data/lib/DhanHQ/errors.rb +4 -0
  25. data/lib/DhanHQ/events/base.rb +203 -0
  26. data/lib/DhanHQ/events/bus.rb +158 -0
  27. data/lib/DhanHQ/events.rb +40 -0
  28. data/lib/DhanHQ/indicators.rb +283 -0
  29. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  30. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  31. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  32. data/lib/DhanHQ/market_data.rb +25 -0
  33. data/lib/DhanHQ/mcp/server.rb +235 -0
  34. data/lib/DhanHQ/mcp.rb +10 -0
  35. data/lib/DhanHQ/models/funds.rb +12 -0
  36. data/lib/DhanHQ/models/holding.rb +42 -0
  37. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  38. data/lib/DhanHQ/models/instrument.rb +80 -14
  39. data/lib/DhanHQ/models/order.rb +95 -0
  40. data/lib/DhanHQ/models/position.rb +66 -0
  41. data/lib/DhanHQ/models/search_result.rb +12 -0
  42. data/lib/DhanHQ/models/trade.rb +13 -0
  43. data/lib/DhanHQ/models/twap_order.rb +136 -0
  44. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  45. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  46. data/lib/DhanHQ/option_analytics.rb +36 -0
  47. data/lib/DhanHQ/rate_limiter.rb +5 -3
  48. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  49. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  50. data/lib/DhanHQ/resources/iceberg_orders.rb +62 -0
  51. data/lib/DhanHQ/resources/orders.rb +2 -0
  52. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  53. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  54. data/lib/DhanHQ/resources/twap_orders.rb +62 -0
  55. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  56. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  57. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  58. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  59. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  60. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  61. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  62. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  63. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  64. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  65. data/lib/DhanHQ/risk/pipeline.rb +72 -0
  66. data/lib/DhanHQ/risk.rb +250 -0
  67. data/lib/DhanHQ/skills/base.rb +183 -0
  68. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  69. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  70. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +84 -0
  71. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  72. data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
  73. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  74. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  75. data/lib/DhanHQ/skills/builtin/square_off_all.rb +42 -0
  76. data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
  77. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  78. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  79. data/lib/DhanHQ/skills/registry.rb +101 -0
  80. data/lib/DhanHQ/skills/workflow.rb +66 -0
  81. data/lib/DhanHQ/skills.rb +29 -0
  82. data/lib/DhanHQ/strategy/base.rb +189 -0
  83. data/lib/DhanHQ/strategy.rb +40 -0
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/ws/decoder.rb +57 -19
  86. data/lib/DhanHQ.rb +3 -0
  87. data/lib/dhan_hq/agent.rb +3 -0
  88. data/lib/dhan_hq/mcp.rb +3 -0
  89. data/lib/dhan_hq.rb +74 -2
  90. data/lib/ta/technical_analysis.rb +3 -1
  91. data/skills/dhanhq-ruby/SKILL.md +207 -0
  92. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  93. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  94. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  95. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  96. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  97. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  98. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  99. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  100. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  101. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  102. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  103. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  104. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  105. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  106. data/skills/dhanhq-ruby/references/funds.md +67 -0
  107. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  108. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  109. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  110. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  111. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  112. data/skills/dhanhq-ruby/references/orders.md +201 -0
  113. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  114. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  115. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  116. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  117. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  118. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  119. metadata +95 -20
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module MarketData
5
+ # A snapshot of option chain data for a single underlying.
6
+ #
7
+ # Wraps the raw OptionChain response into a convenient structure
8
+ # with typed accessors and helper methods for option analysis.
9
+ #
10
+ # @example Build a snapshot from OptionChain response
11
+ # response = DhanHQ::Models::OptionChain.fetch(
12
+ # underlying_scrip: 13,
13
+ # underlying_seg: "IDX_I",
14
+ # expiry: "2024-07-25"
15
+ # )
16
+ # snapshot = DhanHQ::MarketData::OptionSnapshot.from_response(response)
17
+ # snapshot.calls_at(24000) #=> Array of call option data
18
+ # snapshot.puts_at(24000) #=> Array of put option data
19
+ #
20
+ class OptionSnapshot
21
+ OptionLeg = Struct.new(:strike_price, :option_type, :ltp, :bid, :ask,
22
+ :volume, :open_interest, :implied_volatility,
23
+ :delta, :gamma, :theta, :vega) do
24
+ def call?
25
+ option_type == DhanHQ::Constants::OptionType::CALL
26
+ end
27
+
28
+ def put?
29
+ option_type == DhanHQ::Constants::OptionType::PUT
30
+ end
31
+
32
+ def itm?(spot_price)
33
+ return false unless spot_price
34
+
35
+ call? ? strike_price < spot_price : strike_price > spot_price
36
+ end
37
+
38
+ def otm?(spot_price)
39
+ return false unless spot_price
40
+
41
+ call? ? strike_price > spot_price : strike_price < spot_price
42
+ end
43
+
44
+ def atm?(spot_price)
45
+ return false unless spot_price
46
+
47
+ (strike_price - spot_price).abs < 1
48
+ end
49
+ end
50
+
51
+ attr_reader :underlying_scrip, :underlying_seg, :expiry, :legs, :spot_price, :fetched_at
52
+
53
+ def initialize(legs = [], metadata = {})
54
+ @underlying_scrip = metadata[:underlying_scrip]
55
+ @underlying_seg = metadata[:underlying_seg]
56
+ @expiry = metadata[:expiry]
57
+ @spot_price = metadata[:spot_price]
58
+ @legs = legs
59
+ @fetched_at = Time.now
60
+ end
61
+
62
+ # Build an OptionSnapshot from a raw OptionChain API response.
63
+ def self.from_response(response)
64
+ data = response.is_a?(Hash) ? (response[:data] || response["data"] || response) : response
65
+ return new([], {}) unless data.is_a?(Hash)
66
+
67
+ legs = parse_legs(data)
68
+ metadata = {
69
+ underlying_scrip: data[:underlyingScrip] || data["underlyingScrip"],
70
+ underlying_seg: data[:underlyingSeg] || data["underlyingSeg"],
71
+ expiry: data[:expiry] || data["expiry"],
72
+ spot_price: data[:spot] || data["spot"]
73
+ }
74
+
75
+ new(legs, metadata)
76
+ end
77
+
78
+ # Get all call option legs.
79
+ def calls
80
+ @legs.select(&:call?)
81
+ end
82
+
83
+ # Get all put option legs.
84
+ def puts
85
+ @legs.select(&:put?)
86
+ end
87
+
88
+ # Get all unique strike prices.
89
+ def strikes
90
+ @legs.map(&:strike_price).uniq.sort
91
+ end
92
+
93
+ # Get option legs at a specific strike price.
94
+ def at_strike(strike_price)
95
+ @legs.select { |leg| leg.strike_price == strike_price }
96
+ end
97
+
98
+ # Get call option at a specific strike price.
99
+ def call_at(strike_price)
100
+ calls.find { |leg| leg.strike_price == strike_price }
101
+ end
102
+
103
+ # Get put option at a specific strike price.
104
+ def put_at(strike_price)
105
+ puts.find { |leg| leg.strike_price == strike_price }
106
+ end
107
+
108
+ # Get all ITM calls (calls with strike below spot).
109
+ def itm_calls
110
+ return [] unless @spot_price
111
+
112
+ calls.select { |leg| leg.itm?(@spot_price) }
113
+ end
114
+
115
+ # Get all OTM calls (calls with strike above spot).
116
+ def otm_calls
117
+ return [] unless @spot_price
118
+
119
+ calls.select { |leg| leg.otm?(@spot_price) }
120
+ end
121
+
122
+ # Get all ITM puts (puts with strike above spot).
123
+ def itm_puts
124
+ return [] unless @spot_price
125
+
126
+ puts.select { |leg| leg.itm?(@spot_price) }
127
+ end
128
+
129
+ # Get all OTM puts (puts with strike below spot).
130
+ def otm_puts
131
+ return [] unless @spot_price
132
+
133
+ puts.select { |leg| leg.otm?(@spot_price) }
134
+ end
135
+
136
+ # Find the ATM strike (closest to spot price).
137
+ def atm_strike
138
+ return nil unless @spot_price || strikes.any?
139
+
140
+ spot = @spot_price || strikes.first
141
+ strikes.min_by { |strike| (strike - spot).abs }
142
+ end
143
+
144
+ # Get the total open interest across all legs.
145
+ def total_oi
146
+ @legs.sum { |leg| leg.open_interest.to_i }
147
+ end
148
+
149
+ # Get the total volume across all legs.
150
+ def total_volume
151
+ @legs.sum { |leg| leg.volume.to_i }
152
+ end
153
+
154
+ # Calculate put-call ratio by open interest.
155
+ def pcr_by_oi
156
+ call_oi = calls.sum { |leg| leg.open_interest.to_i }
157
+ put_oi = puts.sum { |leg| leg.open_interest.to_i }
158
+ return 0.0 if call_oi.zero?
159
+
160
+ put_oi.to_f / call_oi
161
+ end
162
+
163
+ # Calculate put-call ratio by volume.
164
+ def pcr_by_volume
165
+ call_vol = calls.sum { |leg| leg.volume.to_i }
166
+ put_vol = puts.sum { |leg| leg.volume.to_i }
167
+ return 0.0 if call_vol.zero?
168
+
169
+ put_vol.to_f / call_vol
170
+ end
171
+
172
+ def self.parse_legs(data)
173
+ # Parse from the option chain structure
174
+ ce_data = data[:ce] || data["ce"] || {}
175
+ pe_data = data[:pe] || data["pe"] || {}
176
+
177
+ ce_strikes = ce_data[:strike] || ce_data["strike"] || []
178
+ pe_strikes = pe_data[:strike] || pe_data["strike"] || []
179
+
180
+ # Parse CE (call) and PE (put) legs
181
+ parse_option_legs(ce_data, DhanHQ::Constants::OptionType::CALL, ce_strikes) +
182
+ parse_option_legs(pe_data, DhanHQ::Constants::OptionType::PUT, pe_strikes)
183
+ end
184
+ private_class_method :parse_legs
185
+
186
+ def self.parse_option_legs(data, option_type, strikes)
187
+ return [] unless data.is_a?(Hash)
188
+
189
+ strikes.each_with_index.map do |strike, i|
190
+ build_leg(data, option_type, strike, i)
191
+ end
192
+ end
193
+ private_class_method :parse_option_legs
194
+
195
+ def self.build_leg(data, option_type, strike, index)
196
+ OptionLeg.new(
197
+ strike_price: strike.to_f,
198
+ option_type: option_type,
199
+ ltp: extract_value(data, :ltp, index),
200
+ bid: extract_value(data, :bid, index),
201
+ ask: extract_value(data, :ask, index),
202
+ volume: extract_value(data, :volume, index, as_integer: true),
203
+ open_interest: extract_value(data, :oi, index, as_integer: true),
204
+ implied_volatility: extract_value(data, :iv, index),
205
+ delta: extract_value(data, :delta, index),
206
+ gamma: extract_value(data, :gamma, index),
207
+ theta: extract_value(data, :theta, index),
208
+ vega: extract_value(data, :vega, index)
209
+ )
210
+ end
211
+ private_class_method :build_leg
212
+
213
+ def self.extract_value(data, key, index, as_integer: false)
214
+ values = data[key] || data[key.to_s] || []
215
+ value = values[index]
216
+ return nil if value.nil?
217
+
218
+ as_integer ? value.to_i : value.to_f
219
+ end
220
+ private_class_method :extract_value
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "market_data/market_snapshot"
4
+ require_relative "market_data/ohlc_series"
5
+ require_relative "market_data/option_snapshot"
6
+
7
+ module DhanHQ
8
+ # Higher-level abstractions for market data consumption.
9
+ #
10
+ # Provides typed wrappers around raw API responses for more convenient
11
+ # market data analysis and consumption.
12
+ #
13
+ # @example Quick market snapshot
14
+ # response = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [11536])
15
+ # snapshot = DhanHQ::MarketData::MarketSnapshot.from_response(response)
16
+ # puts snapshot.ltp("NSE_EQ", "11536")
17
+ #
18
+ # @example Historical OHLC series
19
+ # response = DhanHQ::Models::HistoricalData.daily(...)
20
+ # series = DhanHQ::MarketData::OHLCSeries.from_response(response)
21
+ # puts series.average_close
22
+ #
23
+ module MarketData
24
+ end
25
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require_relative "../agent"
6
+ require_relative "../ai"
7
+
8
+ module DhanHQ
9
+ module MCP
10
+ # Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
11
+ class Server
12
+ # Raised for an unrecognized top-level JSON-RPC method (maps to -32601).
13
+ class UnknownMethodError < StandardError; end
14
+ # Raised for a well-formed request with invalid/unknown params (maps to -32602).
15
+ class InvalidParamsError < StandardError; end
16
+
17
+ SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05"].freeze
18
+ DEFAULT_TOOL_CALL_TIMEOUT_SECONDS = 15
19
+
20
+ RESOURCES = [
21
+ { uri: "dhanhq://account/profile", name: "Dhan Profile", description: "Current account profile including client ID, PAN, name, and trading permissions",
22
+ mimeType: "application/json" },
23
+ { uri: "dhanhq://account/funds", name: "Fund Limits", description: "Available balance, used margin, and withdrawal capacity", mimeType: "application/json" },
24
+ { uri: "dhanhq://account/holdings", name: "Portfolio Holdings", description: "Current equity holdings with quantity, average price, and P&L", mimeType: "application/json" },
25
+ { uri: "dhanhq://account/positions", name: "Open Positions", description: "Current F&O and equity positions with net quantity and unrealized P&L",
26
+ mimeType: "application/json" },
27
+ { uri: "dhanhq://account/orders", name: "Recent Orders", description: "Recent order history with status, quantity, and fill details", mimeType: "application/json" },
28
+ { uri: "dhanhq://market/capabilities", name: "Agent Capabilities", description: "All available tools, scopes, risk levels, and version info", mimeType: "application/json" }
29
+ ].freeze
30
+
31
+ PROMPTS = [
32
+ { name: "portfolio_summary", description: "Generate a human-readable summary of your current portfolio, positions, and available funds" },
33
+ { name: "market_analysis", description: "Analyze current market conditions and generate a trading context summary" },
34
+ { name: "risk_report", description: "Review current risk exposure including open positions, P&L, and position limits" },
35
+ { name: "suggest_strategy", description: "Suggest a trading strategy based on current portfolio and market conditions" },
36
+ { name: "order_preview", description: "Preview an order before placing it with risk validation" }
37
+ ].freeze
38
+
39
+ def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env,
40
+ tool_call_timeout: DEFAULT_TOOL_CALL_TIMEOUT_SECONDS)
41
+ @input = input
42
+ @output = output
43
+ @policy = policy
44
+ @tool_call_timeout = tool_call_timeout
45
+ end
46
+
47
+ def run
48
+ @input.each_line { |line| handle_line(line) }
49
+ end
50
+
51
+ def handle_line(line)
52
+ request = JSON.parse(line)
53
+ rescue JSON::ParserError => e
54
+ respond(nil, nil, code: -32_700, message: "Parse error: #{e.message}")
55
+ else
56
+ handle_request(request)
57
+ end
58
+
59
+ private
60
+
61
+ def handle_request(request)
62
+ return unless request.key?("id") # JSON-RPC notification — must not receive a response
63
+
64
+ id = request["id"]
65
+ respond(id, dispatch(request["method"], request["params"] || {}))
66
+ rescue StandardError => e
67
+ respond(id, nil, code: error_code_for(e), message: e.message)
68
+ end
69
+
70
+ def error_code_for(error)
71
+ case error
72
+ when UnknownMethodError then -32_601
73
+ when InvalidParamsError then -32_602
74
+ else -32_603
75
+ end
76
+ end
77
+
78
+ def dispatch(method, params)
79
+ case method
80
+ when "initialize"
81
+ {
82
+ protocolVersion: negotiate_protocol_version(params["protocolVersion"]),
83
+ serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
84
+ capabilities: { tools: {}, resources: {}, prompts: {} }
85
+ }
86
+ when "tools/list"
87
+ { tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
88
+ when "tools/call"
89
+ { content: [{ type: "text", text: JSON.pretty_generate(serialize(call_tool(params))) }] }
90
+ when "resources/list"
91
+ { resources: resource_definitions }
92
+ when "resources/read"
93
+ resource = resources.find { |r| r[:uri] == params["uri"] }
94
+ raise InvalidParamsError, "Unknown resource: #{params["uri"]}" unless resource
95
+
96
+ { contents: [resource_read(resource)] }
97
+ when "prompts/list"
98
+ { prompts: prompt_definitions }
99
+ when "prompts/get"
100
+ prompt = prompts.find { |p| p[:name] == params["name"] }
101
+ raise InvalidParamsError, "Unknown prompt: #{params["name"]}" unless prompt
102
+
103
+ prompt_result(prompt, params.fetch("arguments", {}))
104
+ else
105
+ raise UnknownMethodError, "Unsupported MCP method: #{method}"
106
+ end
107
+ end
108
+
109
+ def negotiate_protocol_version(requested)
110
+ SUPPORTED_PROTOCOL_VERSIONS.include?(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS.last
111
+ end
112
+
113
+ def call_tool(params)
114
+ Timeout.timeout(@tool_call_timeout) do
115
+ DhanHQ::Agent::ToolRegistry.execute(
116
+ params.fetch("name"),
117
+ params.fetch("arguments", {}),
118
+ policy: @policy
119
+ )
120
+ end
121
+ rescue ArgumentError => e
122
+ raise InvalidParamsError, e.message
123
+ rescue Timeout::Error
124
+ raise "Tool call '#{params["name"]}' timed out after #{@tool_call_timeout}s " \
125
+ "(likely blocked on rate-limit backoff) — retry shortly"
126
+ end
127
+
128
+ def resources
129
+ RESOURCES
130
+ end
131
+
132
+ def prompts
133
+ PROMPTS
134
+ end
135
+
136
+ def resource_definitions
137
+ RESOURCES.map { |r| r.except(:handler) }
138
+ end
139
+
140
+ def resource_read(resource)
141
+ handler = resource_handler(resource[:uri])
142
+ data = handler.call
143
+ { uri: resource[:uri], mimeType: "application/json", text: JSON.pretty_generate(serialize(data)) }
144
+ end
145
+
146
+ def resource_handler(uri)
147
+ case uri
148
+ when "dhanhq://account/profile" then -> { DhanHQ::Models::Profile.fetch }
149
+ when "dhanhq://account/funds" then -> { DhanHQ::Models::Funds.fetch }
150
+ when "dhanhq://account/holdings" then -> { DhanHQ::Models::Holding.all }
151
+ when "dhanhq://account/positions" then -> { DhanHQ::Models::Position.all }
152
+ when "dhanhq://account/orders" then -> { DhanHQ::Models::Order.all }
153
+ when "dhanhq://market/capabilities" then -> { DhanHQ::Agent::ToolRegistry.capabilities }
154
+ else raise ArgumentError, "No handler for resource: #{uri}"
155
+ end
156
+ end
157
+
158
+ def prompt_definitions
159
+ PROMPTS.map { |p| { name: p[:name], description: p[:description], arguments: prompt_arguments(p[:name]) } }
160
+ end
161
+
162
+ def prompt_arguments(prompt_name)
163
+ case prompt_name
164
+ when "order_preview"
165
+ [{ name: "transaction_type", description: "BUY or SELL", required: true },
166
+ { name: "security_id", description: "Dhan security ID", required: true },
167
+ { name: "quantity", description: "Number of shares/lots", required: true },
168
+ { name: "exchange_segment", description: "NSE_EQ, NSE_FNO, etc.", required: true },
169
+ { name: "price", description: "Limit price (optional for MARKET)", required: false }]
170
+ when "market_analysis"
171
+ [{ name: "symbol", description: "Ticker symbol (e.g., NIFTY, RELIANCE)", required: false }]
172
+ else
173
+ []
174
+ end
175
+ end
176
+
177
+ def prompt_result(prompt, arguments)
178
+ case prompt[:name]
179
+ when "portfolio_summary"
180
+ holdings = DhanHQ::Models::Holding.all
181
+ positions = DhanHQ::Models::Position.all
182
+ funds = DhanHQ::Models::Funds.fetch
183
+ summary = DhanHQ::AI::PromptHelpers.portfolio_summary(holdings: holdings, positions: positions, funds: funds)
184
+ { messages: [{ role: "user", content: { type: "text", text: summary } }] }
185
+ when "market_analysis"
186
+ symbol = arguments["symbol"] || "NIFTY"
187
+ instrument = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, symbol)
188
+ security_id = instrument&.security_id&.to_i
189
+ if security_id
190
+ snapshot = DhanHQ::Models::MarketFeed.quote(DhanHQ::Constants::ExchangeSegment::IDX_I => [security_id])
191
+ text = "Market snapshot for #{symbol}: #{snapshot}"
192
+ else
193
+ text = "Could not resolve symbol #{symbol} to a security ID for market data."
194
+ end
195
+ { messages: [{ role: "user", content: { type: "text", text: text } }] }
196
+ when "risk_report"
197
+ positions = DhanHQ::Models::Position.all
198
+ text = DhanHQ::AI::PromptHelpers.risk_report(positions: positions)
199
+ { messages: [{ role: "user", content: { type: "text", text: text } }] }
200
+ when "order_preview"
201
+ preview = DhanHQ::Agent::OrderPreview.new(arguments)
202
+ text = preview.valid? ? "Order preview: #{preview.to_h[:summary]}" : "Validation errors: #{preview.errors.join(", ")}"
203
+ { messages: [{ role: "user", content: { type: "text", text: text } }] }
204
+ when "suggest_strategy"
205
+ positions = DhanHQ::Models::Position.all
206
+ funds = DhanHQ::Models::Funds.fetch
207
+ text = "Current portfolio: #{positions.size} open positions, Available: ₹#{funds.available_balance}. Review positions for strategy suggestions."
208
+ { messages: [{ role: "user", content: { type: "text", text: text } }] }
209
+ else
210
+ raise ArgumentError, "Unknown prompt: #{prompt[:name]}"
211
+ end
212
+ end
213
+
214
+ def mcp_tool(tool)
215
+ { name: tool[:name], description: "[#{tool[:risk]}] #{tool[:description]}", inputSchema: tool[:input_schema] }
216
+ end
217
+
218
+ def serialize(value)
219
+ case value
220
+ when Array then value.map { |v| serialize(v) }
221
+ when Hash then value.transform_values { |v| serialize(v) }
222
+ else
223
+ value.respond_to?(:attributes) ? value.attributes : value
224
+ end
225
+ end
226
+
227
+ def respond(id, result, code: nil, message: nil)
228
+ payload = { jsonrpc: "2.0", id: id }
229
+ code ? payload[:error] = { code: code, message: message } : payload[:result] = result
230
+ @output.puts(JSON.generate(payload))
231
+ @output.flush
232
+ end
233
+ end
234
+ end
235
+ end
data/lib/DhanHQ/mcp.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcp/server"
4
+
5
+ module DhanHQ
6
+ # MCP namespace provides Model Context Protocol server for AI agent integration.
7
+ # Enables stdio-based JSON-RPC communication with AI tools and agents.
8
+ module MCP
9
+ end
10
+ end
@@ -29,6 +29,18 @@ module DhanHQ
29
29
  attributes :available_balance, :sod_limit, :collateral_amount, :receiveable_amount, :utilized_amount,
30
30
  :blocked_payout_amount, :withdrawable_balance
31
31
 
32
+ # Returns a concise prompt-friendly summary of funds.
33
+ def to_prompt
34
+ parts = []
35
+ parts << "available=₹#{available_balance}" if available_balance
36
+ parts << "utilized=₹#{utilized_amount}" if utilized_amount
37
+ parts << "withdrawable=₹#{withdrawable_balance}" if withdrawable_balance
38
+ parts << "collateral=₹#{collateral_amount}" if collateral_amount&.positive?
39
+ parts << "sod_limit=₹#{sod_limit}" if sod_limit
40
+ parts << "blocked_payout=₹#{blocked_payout_amount}" if blocked_payout_amount&.positive?
41
+ parts.join(", ")
42
+ end
43
+
32
44
  ##
33
45
  # Normalizes the typo'd `availabelBalance` key from the API response to `available_balance`.
34
46
  #
@@ -33,6 +33,48 @@ module DhanHQ
33
33
  attributes :exchange, :trading_symbol, :security_id, :isin, :total_qty,
34
34
  :dp_qty, :t1_qty, :available_qty, :collateral_qty, :avg_cost_price
35
35
 
36
+ # Returns a concise prompt-friendly summary of the holding.
37
+ def to_prompt
38
+ parts = [
39
+ "#{total_qty}x #{trading_symbol || security_id}",
40
+ "on #{exchange}"
41
+ ]
42
+ parts << "avg_cost=#{avg_cost_price}" if avg_cost_price&.positive?
43
+ parts << "available=#{available_qty}" if available_qty
44
+ parts << "dp=#{dp_qty}" if dp_qty&.positive?
45
+ parts << "t1=#{t1_qty}" if t1_qty&.positive?
46
+ parts << "collateral=#{collateral_qty}" if collateral_qty&.positive?
47
+ parts << "isin=#{isin}" if isin
48
+ parts.join(", ")
49
+ end
50
+
51
+ # Returns true if this holding has pending T1 delivery.
52
+ def pending_delivery?
53
+ t1_qty.to_i.positive?
54
+ end
55
+
56
+ # Returns true if this holding is fully delivered (no T1).
57
+ def delivered?
58
+ t1_qty.to_i.zero?
59
+ end
60
+
61
+ # Returns true if any quantity is pledged as collateral.
62
+ def pledged?
63
+ collateral_qty.to_i.positive?
64
+ end
65
+
66
+ # Returns the percentage of holding that is pledged.
67
+ def pledge_percentage
68
+ return 0.0 if total_qty.to_i.zero?
69
+
70
+ (collateral_qty.to_i.to_f / total_qty.to_i * 100).round(2)
71
+ end
72
+
73
+ # Returns true if this holding is partially pledged.
74
+ def partially_pledged?
75
+ pledged? && collateral_qty.to_i < total_qty.to_i
76
+ end
77
+
36
78
  class << self
37
79
  ##
38
80
  # Provides a shared instance of the Holdings resource.