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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +51 -0
- data/README.md +152 -4
- data/docs/CONSTANTS_REFERENCE.md +3 -2
- data/exe/dhanhq-mcp +7 -0
- data/lib/DhanHQ/agent/order_preview.rb +50 -0
- data/lib/DhanHQ/agent/policy.rb +51 -0
- data/lib/DhanHQ/agent/tool_registry.rb +299 -0
- data/lib/DhanHQ/agent.rb +12 -0
- data/lib/DhanHQ/ai/context_builder.rb +145 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
- data/lib/DhanHQ/ai.rb +27 -0
- data/lib/DhanHQ/auth.rb +0 -1
- data/lib/DhanHQ/client.rb +1 -3
- data/lib/DhanHQ/concerns/order_audit.rb +43 -1
- data/lib/DhanHQ/constants.rb +5 -2
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
- data/lib/DhanHQ/core/auth_api.rb +0 -1
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/events/base.rb +203 -0
- data/lib/DhanHQ/events/bus.rb +158 -0
- data/lib/DhanHQ/events.rb +40 -0
- data/lib/DhanHQ/indicators.rb +283 -0
- data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
- data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
- data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
- data/lib/DhanHQ/market_data.rb +25 -0
- data/lib/DhanHQ/mcp/server.rb +235 -0
- data/lib/DhanHQ/mcp.rb +10 -0
- data/lib/DhanHQ/models/funds.rb +12 -0
- data/lib/DhanHQ/models/holding.rb +42 -0
- data/lib/DhanHQ/models/iceberg_order.rb +139 -0
- data/lib/DhanHQ/models/instrument.rb +80 -14
- data/lib/DhanHQ/models/order.rb +95 -0
- data/lib/DhanHQ/models/position.rb +66 -0
- data/lib/DhanHQ/models/search_result.rb +12 -0
- data/lib/DhanHQ/models/trade.rb +13 -0
- data/lib/DhanHQ/models/twap_order.rb +136 -0
- data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
- data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
- data/lib/DhanHQ/option_analytics.rb +36 -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/iceberg_orders.rb +62 -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 +62 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/options.rb +46 -0
- data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
- data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
- data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
- data/lib/DhanHQ/risk/pipeline.rb +72 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +183 -0
- 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 +84 -0
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
- 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 +42 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
- data/lib/DhanHQ/skills/registry.rb +101 -0
- data/lib/DhanHQ/skills/workflow.rb +66 -0
- data/lib/DhanHQ/skills.rb +29 -0
- data/lib/DhanHQ/strategy/base.rb +189 -0
- data/lib/DhanHQ/strategy.rb +40 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/decoder.rb +57 -19
- data/lib/DhanHQ.rb +3 -0
- data/lib/dhan_hq/agent.rb +3 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq.rb +74 -2
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +207 -0
- data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
- data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
- data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
- data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
- data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
- data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
- data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
- data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
- data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
- data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
- data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
- data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
- data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
- data/skills/dhanhq-ruby/references/error-codes.md +50 -0
- data/skills/dhanhq-ruby/references/funds.md +67 -0
- data/skills/dhanhq-ruby/references/instruments.md +85 -0
- data/skills/dhanhq-ruby/references/live-feed.md +83 -0
- data/skills/dhanhq-ruby/references/market-data.md +119 -0
- data/skills/dhanhq-ruby/references/option-chain.md +71 -0
- data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
- data/skills/dhanhq-ruby/references/orders.md +201 -0
- data/skills/dhanhq-ruby/references/portfolio.md +93 -0
- data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
- data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
- data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
- data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
- data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
- 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
|
data/lib/DhanHQ/models/funds.rb
CHANGED
|
@@ -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.
|