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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module DhanHQ
6
+ module DryRun
7
+ # Remembers the orders a dry run pretended to place, so a follow-up read for one
8
+ # of those simulated ids can be answered locally.
9
+ #
10
+ # This exists because the high-level models re-fetch after placing:
11
+ # `Models::Order.place` and `GlobalStocks::Order.place` both call `find(order_id)`
12
+ # to return the complete record. Without a ledger that GET would leave the process
13
+ # carrying a `DRYRUN-…` id to the live API, which is both a real network call and a
14
+ # guaranteed miss — so dry run would only have worked for direct
15
+ # {DhanHQ::Client#request} calls, not the API people actually use.
16
+ #
17
+ # Entries are per-process and capped; a long rehearsal evicts its oldest records
18
+ # rather than growing without bound.
19
+ class Ledger
20
+ # Maximum simulated orders retained before the oldest are evicted.
21
+ MAX_ENTRIES = 500
22
+
23
+ def initialize(max_entries: MAX_ENTRIES)
24
+ @max_entries = max_entries
25
+ @records = {}
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ # Process-wide ledger used by {DhanHQ::DryRun::Simulator}.
30
+ #
31
+ # @return [Ledger]
32
+ def self.instance
33
+ @instance ||= new
34
+ end
35
+
36
+ # Records a simulated order.
37
+ #
38
+ # @param order_id [String] The simulated order id
39
+ # @param record [Hash] The response to replay for reads of that id
40
+ # @return [Hash] The stored record
41
+ def record(order_id, record)
42
+ @mutex.synchronize do
43
+ @records.delete(order_id)
44
+ @records[order_id] = record
45
+ @records.shift while @records.size > @max_entries
46
+ record
47
+ end
48
+ end
49
+
50
+ # Fetches a previously simulated order.
51
+ #
52
+ # @param order_id [String]
53
+ # @return [Hash, nil] nil when the id was never simulated in this process
54
+ def fetch(order_id)
55
+ @mutex.synchronize { @records[order_id] }
56
+ end
57
+
58
+ # @return [Integer] Number of retained records
59
+ def size
60
+ @mutex.synchronize { @records.size }
61
+ end
62
+
63
+ # Drops every retained record. Intended for test isolation.
64
+ #
65
+ # @return [void]
66
+ def clear
67
+ @mutex.synchronize { @records.clear }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+
6
+ module DhanHQ
7
+ module DryRun
8
+ # Answers state-changing requests locally instead of sending them.
9
+ #
10
+ # Reads still go to the API, so market data, the option chain, positions and
11
+ # holdings stay live and a strategy can be rehearsed against real prices. The one
12
+ # exception is a read for an order this simulator itself invented: those carry the
13
+ # {ID_PREFIX} sentinel, can only exist because of a dry run, and are replayed from
14
+ # the {Ledger} so the models' place-then-refetch flow completes without touching
15
+ # the network.
16
+ #
17
+ # Responses are shaped per endpoint family, because callers inspect them: order
18
+ # placements need an `orderId` for the models to treat the write as successful, and
19
+ # the basket endpoint needs an `orders` array or {DhanHQ::Models::MultiOrder} sees
20
+ # a failure.
21
+ class Simulator
22
+ # Marks an order id as fabricated by a dry run.
23
+ ID_PREFIX = "DRYRUN-"
24
+
25
+ # Extracts a fabricated id out of a request path.
26
+ ID_PATTERN = /#{Regexp.escape(ID_PREFIX)}[A-Za-z0-9]+/
27
+
28
+ def initialize(ledger: Ledger.instance)
29
+ @ledger = ledger
30
+ end
31
+
32
+ # Whether this request should be answered locally.
33
+ #
34
+ # @param method [Symbol] HTTP method
35
+ # @param path [String, nil] Request path
36
+ # @return [Boolean]
37
+ def simulates?(method, path)
38
+ return false unless DhanHQ.configuration&.dry_run?
39
+
40
+ WritePaths.mutating?(method, path) || replayable_read?(method, path)
41
+ end
42
+
43
+ # Builds — and logs — the response for a simulated request.
44
+ #
45
+ # @param method [Symbol] HTTP method
46
+ # @param path [String] Request path
47
+ # @param payload [Hash] The request body that will not be sent
48
+ # @return [ActiveSupport::HashWithIndifferentAccess]
49
+ def response_for(method, path, payload)
50
+ return replay(path) if replayable_read?(method, path)
51
+
52
+ log(method, path, payload)
53
+ body = simulated_body(method, path, payload)
54
+ body.merge("dryRun" => true, "method" => method.to_s.upcase, "path" => path)
55
+ .with_indifferent_access
56
+ end
57
+
58
+ private
59
+
60
+ # A GET for an id this simulator minted. Only reachable in dry run, so
61
+ # intercepting it never shadows a real read.
62
+ def replayable_read?(method, path)
63
+ method == :get && path.to_s.include?(ID_PREFIX)
64
+ end
65
+
66
+ def replay(path)
67
+ order_id = path.to_s[ID_PATTERN]
68
+ record = @ledger.fetch(order_id)
69
+ return record.with_indifferent_access if record
70
+
71
+ # Simulated in another process, or evicted from the ledger. Answer with the
72
+ # minimum a caller needs rather than falling through to a live request.
73
+ { "orderId" => order_id, "orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT, "dryRun" => true }
74
+ .with_indifferent_access
75
+ end
76
+
77
+ def simulated_body(method, path, payload)
78
+ if WritePaths.multi_order?(path)
79
+ simulated_basket(payload)
80
+ elsif WritePaths.order_placement?(method, path)
81
+ simulated_order(payload)
82
+ else
83
+ {}
84
+ end
85
+ end
86
+
87
+ # Echoes the submitted fields back alongside the fabricated id, so a model built
88
+ # from the ledger record looks like the order that would have been placed.
89
+ def simulated_order(payload)
90
+ order_id = generate_id
91
+ record = stringify(payload).merge(
92
+ "orderId" => order_id,
93
+ "orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT,
94
+ "dryRun" => true
95
+ )
96
+ @ledger.record(order_id, record)
97
+ { "orderId" => order_id, "orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT }
98
+ end
99
+
100
+ # One result per submitted leg, keyed by the caller's sequence, mirroring the
101
+ # real basket response.
102
+ def simulated_basket(payload)
103
+ legs = stringify(payload)["orders"]
104
+ legs = [] unless legs.is_a?(Array)
105
+
106
+ orders = legs.each_with_index.map do |leg, index|
107
+ order_id = generate_id
108
+ sequence = (leg.is_a?(Hash) ? leg["sequence"] : nil) || (index + 1).to_s
109
+ @ledger.record(order_id, (leg.is_a?(Hash) ? leg : {}).merge("orderId" => order_id, "dryRun" => true))
110
+ {
111
+ "orderId" => order_id,
112
+ "sequence" => sequence,
113
+ "orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT
114
+ }
115
+ end
116
+
117
+ { "orders" => orders }
118
+ end
119
+
120
+ def generate_id
121
+ "#{ID_PREFIX}#{SecureRandom.hex(6).upcase}"
122
+ end
123
+
124
+ def stringify(payload)
125
+ payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : {}
126
+ end
127
+
128
+ def log(method, path, payload)
129
+ DhanHQ.logger&.warn(
130
+ JSON.generate(
131
+ event: "DHAN_DRY_RUN",
132
+ method: method.to_s.upcase,
133
+ path: path,
134
+ payload: payload.is_a?(Hash) ? payload : nil
135
+ )
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
@@ -11,6 +11,29 @@ module DhanHQ
11
11
  hash.transform_keys { |key| key.to_s.camelize(:lower) }
12
12
  end
13
13
 
14
+ # Recursively convert keys from snake_case to camelCase, descending into nested
15
+ # hashes and through arrays.
16
+ #
17
+ # {#camelize_keys} only rewrites the top level, which is correct for the flat
18
+ # payloads most endpoints take. Endpoints whose body nests objects — the basket
19
+ # order `orders` array, an alert's `condition` — need this instead, or the nested
20
+ # fields reach the API still in snake_case and are rejected.
21
+ #
22
+ # @param value [Hash, Array, Object] The value to convert
23
+ # @return [Hash, Array, Object] The converted value; non-collections pass through
24
+ def deep_camelize_keys(value)
25
+ case value
26
+ when Hash
27
+ value.each_with_object({}) do |(key, nested), out|
28
+ out[key.to_s.camelize(:lower)] = deep_camelize_keys(nested)
29
+ end
30
+ when Array
31
+ value.map { |nested| deep_camelize_keys(nested) }
32
+ else
33
+ value
34
+ end
35
+ end
36
+
14
37
  # Convert keys from snake_case to TitleCase
15
38
  #
16
39
  # @param hash [Hash] The hash to convert
@@ -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
 
@@ -45,7 +45,10 @@ module DhanHQ
45
45
  def create(params)
46
46
  normalized = snake_case(params)
47
47
  validate_params!(normalized, DhanHQ::Contracts::AlertOrderContract)
48
- response = resource.create(camelize_keys(normalized))
48
+ # `condition` and every entry of `orders` are nested objects whose fields the
49
+ # API expects in camelCase (AlertCondition#comparisonType, #securityId, …), so
50
+ # a shallow camelize would leave them snake_cased on the wire.
51
+ response = resource.create(deep_camelize_keys(normalized))
49
52
  return nil unless response.is_a?(Hash) && response["alertId"]
50
53
 
51
54
  find(response["alertId"])
@@ -69,7 +72,7 @@ module DhanHQ
69
72
  normalized = snake_case(params)
70
73
  validate_params!(normalized, DhanHQ::Contracts::AlertOrderContract)
71
74
  payload = normalized.merge(alert_id: alert_id)
72
- response = resource.update(alert_id, camelize_keys(payload))
75
+ response = resource.update(alert_id, deep_camelize_keys(payload))
73
76
  return nil unless success_response?(response)
74
77
 
75
78
  find(alert_id)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module GlobalStocks
6
+ ##
7
+ # USD fund limit for Global Stocks trading.
8
+ #
9
+ # This is a separate balance from the domestic INR limit exposed by
10
+ # {DhanHQ::Models::Funds}.
11
+ #
12
+ # @example
13
+ # funds = DhanHQ::Models::GlobalStocks::Funds.fetch
14
+ # puts "Available: $#{funds.available_cash}"
15
+ # puts "Unsettled: $#{funds.unsettled_cash}"
16
+ #
17
+ class Funds < BaseModel
18
+ HTTP_PATH = "/v2/globalstocks/fundlimit"
19
+
20
+ attributes :dhan_client_id, :available_cash, :cash_on_account, :actual_cash,
21
+ :settled_cash, :unsettled_cash, :margin_utilized
22
+
23
+ # Returns a concise prompt-friendly summary of the USD balance.
24
+ def to_prompt
25
+ parts = []
26
+ parts << "available=$#{available_cash}" if available_cash
27
+ parts << "settled=$#{settled_cash}" if settled_cash
28
+ parts << "unsettled=$#{unsettled_cash}" if unsettled_cash
29
+ parts << "utilized=$#{margin_utilized}" if margin_utilized&.positive?
30
+ parts.join(", ")
31
+ end
32
+
33
+ class << self
34
+ # @return [DhanHQ::Resources::GlobalStocks::Funds]
35
+ def resource
36
+ @resource ||= DhanHQ::Resources::GlobalStocks::Funds.new
37
+ end
38
+
39
+ # Fetches the Global Stocks fund limit.
40
+ #
41
+ # @return [Funds]
42
+ def fetch
43
+ new(resource.fetch, skip_validation: true)
44
+ end
45
+
46
+ # Convenience accessor for the available USD cash.
47
+ #
48
+ # @return [Float]
49
+ def balance
50
+ fetch.available_cash.to_f
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module GlobalStocks
6
+ ##
7
+ # A single Global Stocks (US equity) holding.
8
+ #
9
+ # Unlike domestic holdings, US holdings report live valuation directly
10
+ # (+ltp+, +current_value+, +gain_value+), so no quote lookup is needed to
11
+ # compute unrealised P&L.
12
+ #
13
+ # @example Portfolio with unrealised P&L
14
+ # holdings = DhanHQ::Models::GlobalStocks::Holding.all
15
+ # holdings.each do |h|
16
+ # puts "#{h.trading_symbol}: #{h.quantity} @ $#{h.avg_cost_price} (#{h.gain_percentage}%)"
17
+ # end
18
+ # puts "Total: $#{DhanHQ::Models::GlobalStocks::Holding.total_current_value}"
19
+ #
20
+ class Holding < BaseModel
21
+ HTTP_PATH = "/v2/globalstocks/holdings"
22
+
23
+ attributes :dhan_client_id, :trading_symbol, :display_name, :security_id,
24
+ :exchange, :quantity, :avg_cost_price, :cost_value, :current_value,
25
+ :gain_value, :ltp, :prev_close, :long_term_flag, :last_updated
26
+
27
+ # Returns a concise prompt-friendly summary of the holding.
28
+ def to_prompt
29
+ parts = ["#{quantity}x #{trading_symbol || security_id}"]
30
+ parts << "avg_cost=$#{avg_cost_price}" if avg_cost_price&.positive?
31
+ parts << "ltp=$#{ltp}" if ltp&.positive?
32
+ parts << "value=$#{current_value}" if current_value
33
+ parts << "pnl=$#{gain_value} (#{gain_percentage}%)" if gain_value
34
+ parts.join(", ")
35
+ end
36
+
37
+ # Unrealised gain as a percentage of cost.
38
+ #
39
+ # @return [Float] 0.0 when cost value is unknown or zero.
40
+ def gain_percentage
41
+ cost = cost_value.to_f
42
+ return 0.0 if cost.zero?
43
+
44
+ (gain_value.to_f / cost * 100).round(2)
45
+ end
46
+
47
+ # @return [Boolean] True when the position is in profit.
48
+ def profitable?
49
+ gain_value.to_f.positive?
50
+ end
51
+
52
+ # @return [Boolean] True for a fractional-share position.
53
+ def fractional?
54
+ quantity.is_a?(Float) && (quantity % 1 != 0)
55
+ end
56
+
57
+ # Change against the previous close, per share.
58
+ #
59
+ # @return [Float, nil] nil when either price is unavailable.
60
+ def day_change
61
+ return nil unless ltp && prev_close
62
+
63
+ (ltp.to_f - prev_close.to_f).round(4)
64
+ end
65
+
66
+ class << self
67
+ # @return [DhanHQ::Resources::GlobalStocks::Holdings]
68
+ def resource
69
+ @resource ||= DhanHQ::Resources::GlobalStocks::Holdings.new
70
+ end
71
+
72
+ # Retrieves all Global Stocks holdings.
73
+ #
74
+ # @return [Array<Holding>] Empty array when there are no US holdings.
75
+ def all
76
+ response = resource.all
77
+ return [] unless response.is_a?(Array)
78
+
79
+ response.map { |holding| new(holding, skip_validation: true) }
80
+ rescue DhanHQ::NoHoldingsError
81
+ []
82
+ end
83
+
84
+ # Total market value of the US portfolio.
85
+ #
86
+ # @return [Float]
87
+ def total_current_value
88
+ all.sum { |holding| holding.current_value.to_f }
89
+ end
90
+
91
+ # Total unrealised P&L across the US portfolio.
92
+ #
93
+ # @return [Float]
94
+ def total_gain
95
+ all.sum { |holding| holding.gain_value.to_f }
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end