DhanHQ 3.0.1 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +152 -4
  5. data/docs/CONSTANTS_REFERENCE.md +3 -2
  6. data/exe/dhanhq-mcp +7 -0
  7. data/lib/DhanHQ/agent/tool_registry.rb +51 -2
  8. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  9. data/lib/DhanHQ/constants.rb +3 -2
  10. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  11. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
  12. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  13. data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
  14. data/lib/DhanHQ/mcp/server.rb +172 -9
  15. data/lib/DhanHQ/models/instrument.rb +44 -14
  16. data/lib/DhanHQ/rate_limiter.rb +5 -3
  17. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  18. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  19. data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
  20. data/lib/DhanHQ/resources/orders.rb +2 -0
  21. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  22. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  23. data/lib/DhanHQ/resources/twap_orders.rb +1 -0
  24. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  25. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  26. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  27. data/lib/DhanHQ/risk/pipeline.rb +8 -1
  28. data/lib/DhanHQ/skills/base.rb +54 -3
  29. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  30. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  31. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
  32. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  33. data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
  34. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  35. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  36. data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
  37. data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
  38. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  39. data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
  40. data/lib/DhanHQ/version.rb +1 -1
  41. data/lib/dhan_hq.rb +47 -0
  42. data/skills/dhanhq-ruby/SKILL.md +174 -41
  43. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  44. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  45. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  46. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  47. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  48. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  49. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  50. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  51. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  52. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  53. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  54. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  55. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  56. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  57. data/skills/dhanhq-ruby/references/funds.md +67 -0
  58. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  59. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  60. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  61. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  62. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  63. data/skills/dhanhq-ruby/references/orders.md +200 -6
  64. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  65. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  66. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  67. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  68. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  69. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  70. metadata +39 -3
  71. data/skills/dhanhq-ruby/references/market_data.md +0 -3
@@ -1,16 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "timeout"
4
5
  require_relative "../agent"
6
+ require_relative "../ai"
5
7
 
6
8
  module DhanHQ
7
9
  module MCP
8
10
  # Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
9
11
  class Server
10
- def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env)
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)
11
41
  @input = input
12
42
  @output = output
13
43
  @policy = policy
44
+ @tool_call_timeout = tool_call_timeout
14
45
  end
15
46
 
16
47
  def run
@@ -19,32 +50,164 @@ module DhanHQ
19
50
 
20
51
  def handle_line(line)
21
52
  request = JSON.parse(line)
22
- respond(request["id"], dispatch(request["method"], request["params"] || {}))
23
- rescue StandardError => e
24
- respond(nil, nil, code: -32_000, message: e.message)
53
+ rescue JSON::ParserError => e
54
+ respond(nil, nil, code: -32_700, message: "Parse error: #{e.message}")
55
+ else
56
+ handle_request(request)
25
57
  end
26
58
 
27
59
  private
28
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
+
29
78
  def dispatch(method, params)
30
79
  case method
31
80
  when "initialize"
32
81
  {
33
- protocolVersion: "2024-11-05",
82
+ protocolVersion: negotiate_protocol_version(params["protocolVersion"]),
34
83
  serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
35
- capabilities: { tools: {} }
84
+ capabilities: { tools: {}, resources: {}, prompts: {} }
36
85
  }
37
86
  when "tools/list"
38
87
  { tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
39
88
  when "tools/call"
40
- result = DhanHQ::Agent::ToolRegistry.execute(
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(
41
116
  params.fetch("name"),
42
117
  params.fetch("arguments", {}),
43
118
  policy: @policy
44
119
  )
45
- { content: [{ type: "text", text: JSON.pretty_generate(serialize(result)) }] }
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 } }] }
46
209
  else
47
- raise ArgumentError, "Unsupported MCP method: #{method}"
210
+ raise ArgumentError, "Unknown prompt: #{prompt[:name]}"
48
211
  end
49
212
  end
50
213
 
@@ -63,25 +63,55 @@ module DhanHQ
63
63
  exact_match = options[:exact_match] || false
64
64
  case_sensitive = options[:case_sensitive] || false
65
65
 
66
- instruments = by_segment(exchange_segment)
67
- return nil if instruments.empty?
66
+ csv_text = resource.by_segment(exchange_segment)
67
+ return nil unless csv_text.is_a?(String) && !csv_text.empty?
68
68
 
69
+ require "csv"
69
70
  search_symbol = case_sensitive ? symbol : symbol.upcase
70
71
 
71
- instruments.find do |instrument|
72
+ # Scan raw CSV rows and instantiate only the match — building a full
73
+ # Instrument for every row in a large segment (NSE_EQ is ~219k rows)
74
+ # is minutes-slow and can use gigabytes of memory (see #by_segment).
75
+ row = CSV.parse(csv_text, headers: true).find do |r|
72
76
  # For equity instruments, prefer underlying_symbol over symbol_name
73
- instrument_symbol = if instrument.instrument == DhanHQ::Constants::InstrumentType::EQUITY && instrument.underlying_symbol
74
- case_sensitive ? instrument.underlying_symbol : instrument.underlying_symbol.upcase
75
- else
76
- case_sensitive ? instrument.symbol_name : instrument.symbol_name.upcase
77
- end
78
-
79
- if exact_match
80
- instrument_symbol == search_symbol
81
- else
82
- instrument_symbol.include?(search_symbol)
83
- end
77
+ row_symbol = if r["INSTRUMENT"] == DhanHQ::Constants::InstrumentType::EQUITY && !r["UNDERLYING_SYMBOL"].to_s.empty?
78
+ r["UNDERLYING_SYMBOL"]
79
+ else
80
+ r["SYMBOL_NAME"]
81
+ end
82
+ next false if row_symbol.nil?
83
+
84
+ comparable = case_sensitive ? row_symbol : row_symbol.upcase
85
+ exact_match ? comparable == search_symbol : comparable.include?(search_symbol)
84
86
  end
87
+
88
+ return nil unless row
89
+
90
+ new(normalize_csv_row(row), skip_validation: true)
91
+ end
92
+
93
+ # Find a specific instrument within a segment by its security ID.
94
+ # @param exchange_segment [String] The exchange segment (e.g., "NSE_EQ", "NSE_FNO")
95
+ # @param security_id [String, Integer] The Dhan security ID
96
+ # @return [Instrument, nil] The found instrument or nil if not found
97
+ # @example
98
+ # instrument = DhanHQ::Models::Instrument.find_by_security_id("NSE_EQ", "2885")
99
+ # puts instrument.symbol_name # => "RELIANCE"
100
+ def find_by_security_id(exchange_segment, security_id)
101
+ validate_params!({ exchange_segment: exchange_segment }, DhanHQ::Contracts::InstrumentListContract)
102
+
103
+ csv_text = resource.by_segment(exchange_segment)
104
+ return nil unless csv_text.is_a?(String) && !csv_text.empty?
105
+
106
+ require "csv"
107
+ # Scan raw CSV rows and instantiate only the match — building a full
108
+ # Instrument (and its per-attribute singleton methods) for every row
109
+ # in a large segment (NSE_EQ is ~219k rows) is minutes-slow and can
110
+ # use gigabytes of memory. See #by_segment for the bulk path.
111
+ row = CSV.parse(csv_text, headers: true).find { |r| r["SECURITY_ID"].to_s == security_id.to_s }
112
+ return nil unless row
113
+
114
+ new(normalize_csv_row(row), skip_validation: true)
85
115
  end
86
116
 
87
117
  # Find a specific instrument across all exchange segments.
@@ -5,10 +5,12 @@ require "concurrent"
5
5
  module DhanHQ
6
6
  # Coarse-grained in-memory throttler matching the platform rate limits.
7
7
  class RateLimiter
8
- # Per-interval thresholds keyed by API type.
8
+ # Per-interval thresholds keyed by API type, matching the published
9
+ # DhanHQ rate-limit table (Order APIs: 10/sec, 100,000/day;
10
+ # Data APIs: 5/sec, 7,000/day; Market Quote: 1/sec; Option Chain: 1 per 3 sec).
9
11
  RATE_LIMITS = {
10
- order_api: { per_second: 25, per_minute: 250, per_hour: 1000, per_day: 7000 },
11
- data_api: { per_second: 5, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 100_000 },
12
+ order_api: { per_second: 10, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 100_000 },
13
+ data_api: { per_second: 5, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 7_000 },
12
14
  quote_api: { per_second: 1, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: Float::INFINITY },
13
15
  option_chain: { per_second: 1.0 / 3, per_minute: 20, per_hour: 600, per_day: 4800 },
14
16
  non_trading_api: { per_second: 20, per_minute: Float::INFINITY, per_hour: Float::INFINITY,
@@ -13,6 +13,7 @@ module DhanHQ
13
13
 
14
14
  def create(params)
15
15
  ensure_live_trading!
16
+ run_risk_checks!(params)
16
17
  log_order_context("DHAN_ALERT_ORDER_ATTEMPT", params)
17
18
  super
18
19
  end
@@ -26,6 +26,7 @@ module DhanHQ
26
26
  # @return [Hash]
27
27
  def create(params)
28
28
  ensure_live_trading!
29
+ run_risk_checks!(params)
29
30
  log_order_context("DHAN_FOREVER_ORDER_ATTEMPT", params)
30
31
  post("/orders", params: params)
31
32
  end
@@ -24,6 +24,7 @@ module DhanHQ
24
24
  # @return [Hash]
25
25
  def create(params)
26
26
  ensure_live_trading!
27
+ run_risk_checks!(params)
27
28
  log_order_context("DHAN_ICEBERG_ORDER_ATTEMPT", params)
28
29
  post("", params: params)
29
30
  end
@@ -17,6 +17,7 @@ module DhanHQ
17
17
 
18
18
  def create(params)
19
19
  ensure_live_trading!
20
+ run_risk_checks!(params)
20
21
  log_order_context("DHAN_ORDER_ATTEMPT", params)
21
22
  validate_place_order!(params)
22
23
  post("", params: params)
@@ -31,6 +32,7 @@ module DhanHQ
31
32
 
32
33
  def slicing(params)
33
34
  ensure_live_trading!
35
+ run_risk_checks!(params)
34
36
  log_order_context("DHAN_ORDER_SLICING_ATTEMPT", params)
35
37
  validate_place_order!(params)
36
38
  post("/slicing", params: params)
@@ -19,6 +19,7 @@ module DhanHQ
19
19
  # @return [Hash] API response containing pnlExitStatus and message.
20
20
  def configure(params)
21
21
  ensure_live_trading!
22
+ run_risk_checks!(params)
22
23
  log_order_context("DHAN_PNL_EXIT_CONFIGURE_ATTEMPT", params)
23
24
  post("", params: params)
24
25
  end
@@ -28,6 +28,7 @@ module DhanHQ
28
28
  # @return [Hash]
29
29
  def create(params)
30
30
  ensure_live_trading!
31
+ run_risk_checks!(params)
31
32
  log_order_context("DHAN_SUPER_ORDER_ATTEMPT", params)
32
33
  post("", params: params)
33
34
  end
@@ -24,6 +24,7 @@ module DhanHQ
24
24
  # @return [Hash]
25
25
  def create(params)
26
26
  ensure_live_trading!
27
+ run_risk_checks!(params)
27
28
  log_order_context("DHAN_TWAP_ORDER_ATTEMPT", params)
28
29
  post("", params: params)
29
30
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces maximum portfolio concentration in any single symbol.
7
+ class Concentration
8
+ MAX_CONCENTRATION_PCT = 25.0
9
+
10
+ def self.run!(args:, **_unused)
11
+ symbol = args["trading_symbol"] || args["security_id"]
12
+ return unless symbol
13
+
14
+ funds = DhanHQ::Models::Funds.fetch
15
+ available = funds.available_balance.to_f
16
+ return if available <= 0
17
+
18
+ positions = DhanHQ::Models::Position.all
19
+ symbol_positions = positions.select do |p|
20
+ sym = p.trading_symbol || p.security_id
21
+ sym.to_s == symbol.to_s
22
+ end
23
+
24
+ current_exposure = symbol_positions.sum do |p|
25
+ p.net_qty.to_i.abs * p.cost_price.to_f
26
+ end
27
+
28
+ concentration_pct = (current_exposure / available) * 100.0
29
+ return if concentration_pct <= MAX_CONCENTRATION_PCT
30
+
31
+ raise DhanHQ::RiskViolation,
32
+ "Concentration #{concentration_pct.round(1)}% exceeds #{MAX_CONCENTRATION_PCT}% limit for #{symbol}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces daily maximum loss limit across all positions.
7
+ class MaxLoss
8
+ DAILY_MAX_LOSS = 50_000
9
+
10
+ def self.run!(**_unused)
11
+ positions = DhanHQ::Models::Position.all
12
+ total_unrealized_loss = positions.sum do |p|
13
+ p.unrealized_profit.to_f
14
+ end
15
+
16
+ return if total_unrealized_loss >= -DAILY_MAX_LOSS
17
+
18
+ raise DhanHQ::RiskViolation,
19
+ "Daily loss limit of ₹#{DAILY_MAX_LOSS} exceeded (current: ₹#{total_unrealized_loss.round(0)})"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces maximum number of concurrent open positions.
7
+ class PositionLimits
8
+ MAX_OPEN_POSITIONS = 20
9
+
10
+ def self.run!(**_unused)
11
+ positions = DhanHQ::Models::Position.all
12
+ open_count = positions.count do |p|
13
+ p.net_qty.to_i != 0
14
+ end
15
+
16
+ return if open_count < MAX_OPEN_POSITIONS
17
+
18
+ raise DhanHQ::RiskViolation,
19
+ "Maximum #{MAX_OPEN_POSITIONS} open positions exceeded (#{open_count} open)"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -30,13 +30,19 @@ module DhanHQ
30
30
  Checks::ProductSupport,
31
31
  Checks::OrderType,
32
32
  Checks::Quantity,
33
- Checks::MarketHours
33
+ Checks::MarketHours,
34
+ Checks::PositionLimits,
35
+ Checks::Concentration
34
36
  ].freeze
35
37
 
36
38
  OPTION_CHECKS = [
37
39
  Checks::Options
38
40
  ].freeze
39
41
 
42
+ DAILY_CHECKS = [
43
+ Checks::MaxLoss
44
+ ].freeze
45
+
40
46
  # Run all applicable risk checks.
41
47
  #
42
48
  # @param instrument [Object] instrument with trading metadata
@@ -49,6 +55,7 @@ module DhanHQ
49
55
  def self.run!(instrument:, args:, now: Time.now, type: :equity)
50
56
  run_checks!(CHECKS, instrument, args, now)
51
57
  run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
58
+ run_checks!(DAILY_CHECKS, instrument, args, now)
52
59
  true
53
60
  end
54
61
  # rubocop:enable Naming/PredicateMethod
@@ -23,7 +23,7 @@ module DhanHQ
23
23
  # end
24
24
  #
25
25
  # def get_spot_price(ctx)
26
- # ctx[:spot_price] = ctx[:instrument].ltp[:ltp]
26
+ # ctx[:spot_price] = ctx[:instrument].ltp
27
27
  # ctx
28
28
  # end
29
29
  #
@@ -56,6 +56,28 @@ module DhanHQ
56
56
  @steps.sort_by! { |s| s[:priority] }
57
57
  end
58
58
 
59
+ # MCP risk level for this skill (defaults to the most conservative tier
60
+ # so a skill that forgets to declare one fails safe/write-gated).
61
+ #
62
+ # @param level [String, nil] one of read_only, trade_adjacent_read, live_write, destructive_write
63
+ def risk(level = nil)
64
+ level ? (@risk = level) : (@risk || "destructive_write")
65
+ end
66
+
67
+ # MCP policy scope required to invoke this skill.
68
+ #
69
+ # @param value [String, nil] e.g. "orders:read", "orders:write"
70
+ def scope(value = nil)
71
+ value ? (@scope = value) : (@scope || "orders:write")
72
+ end
73
+
74
+ # Human-readable description shown to MCP/LLM clients in tools/list.
75
+ #
76
+ # @param text [String, nil] one-line summary of what the skill does
77
+ def description(text = nil)
78
+ text ? (@description = text) : @description
79
+ end
80
+
59
81
  # Accessor for defined parameters.
60
82
  def params
61
83
  @params || {}
@@ -104,9 +126,9 @@ module DhanHQ
104
126
  self.class.name || self.class.to_s
105
127
  end
106
128
 
107
- # Skill description (override in subclasses).
129
+ # Skill description (declare via the class-level `description` macro; falls back to class name).
108
130
  def description
109
- self.class.to_s
131
+ self.class.description || self.class.to_s
110
132
  end
111
133
 
112
134
  # List of parameter definitions for this skill.
@@ -116,6 +138,35 @@ module DhanHQ
116
138
 
117
139
  private
118
140
 
141
+ # Real DhanHQ::Models::OptionChain#fetch shape: { last_price:, strikes: [{ strike:, call: {...}, put: {...} }] }.
142
+ # Nearest strike to a target price — always returns an entry (never nil) unless the chain is empty.
143
+ def nearest_strike(chain, target_price)
144
+ strikes = chain[:strikes]
145
+ return nil if strikes.nil? || strikes.empty?
146
+
147
+ strikes.min_by { |s| (s[:strike].to_f - target_price.to_f).abs }
148
+ end
149
+
150
+ # Exact strike match within tolerance — nil if no strike sits on that price.
151
+ def find_strike(chain, target_price, tolerance: 0.001)
152
+ strikes = chain[:strikes]
153
+ return nil if strikes.nil? || strikes.empty?
154
+
155
+ strikes.find { |s| (s[:strike].to_f - target_price.to_f).abs < tolerance }
156
+ end
157
+
158
+ def leg_side(strike_entry, option_type)
159
+ option_type == "CE" ? strike_entry[:call] : strike_entry[:put]
160
+ end
161
+
162
+ def leg_security_id(strike_entry, option_type)
163
+ leg_side(strike_entry, option_type)[:security_id]
164
+ end
165
+
166
+ def leg_premium(strike_entry, option_type)
167
+ leg_side(strike_entry, option_type)[:last_price]
168
+ end
169
+
119
170
  def build_context(args)
120
171
  ctx = {}
121
172
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a bear call spread (sell OTM call, buy further OTM call).
7
+ #
8
+ # Steps: find instrument → spot price → option chain →
9
+ # select strikes → build intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("bear_call_spread",
13
+ # symbol: "NIFTY",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 50
16
+ # )
17
+ #
18
+ class BearCallSpread < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a bear call spread: sell an OTM call, buy a further OTM call for defined risk."
22
+
23
+ param :symbol, type: :string, required: true
24
+ param :expiry, type: :string, required: true
25
+ param :quantity, type: :integer, default: 50
26
+ param :spread_width, type: :number, default: 200
27
+ param :max_loss, type: :number, default: 5000
28
+
29
+ step :find_instrument, priority: 1
30
+ step :get_spot_price, priority: 2
31
+ step :get_option_chain, priority: 3
32
+ step :select_strikes, priority: 4
33
+ step :build_intent, priority: 5
34
+
35
+ def find_instrument(ctx)
36
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
37
+ ctx
38
+ end
39
+
40
+ def get_spot_price(ctx)
41
+ ctx[:spot_price] = ctx[:instrument].ltp
42
+ ctx
43
+ end
44
+
45
+ def get_option_chain(ctx)
46
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
47
+ ctx
48
+ end
49
+
50
+ def select_strikes(ctx)
51
+ spot = ctx[:spot_price].to_f
52
+ chain = ctx[:chain]
53
+ spread = ctx[:spread_width].to_f
54
+
55
+ atm_strike_price = nearest_strike(chain, spot)[:strike].to_f
56
+
57
+ short_call = find_strike(chain, atm_strike_price + spread)
58
+ long_call = find_strike(chain, atm_strike_price + (spread * 2))
59
+
60
+ raise ArgumentError, "Could not build bear call spread — insufficient strikes in chain" unless short_call && long_call
61
+
62
+ ctx[:legs] = [
63
+ { action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: short_call[:strike],
64
+ security_id: leg_security_id(short_call, "CE") },
65
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: long_call[:strike],
66
+ security_id: leg_security_id(long_call, "CE") }
67
+ ]
68
+ ctx
69
+ end
70
+
71
+ def build_intent(ctx)
72
+ ctx[:intent] = {
73
+ trade_type: "BEAR_CALL_SPREAD",
74
+ symbol: ctx[:symbol],
75
+ expiry: ctx[:expiry],
76
+ quantity: ctx[:quantity],
77
+ spread_width: ctx[:spread_width],
78
+ max_loss: ctx[:max_loss],
79
+ legs: ctx[:legs],
80
+ note: "Bear call spread prepared. Await human confirmation before execution."
81
+ }
82
+ ctx
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end