DhanHQ 3.0.0 → 3.0.1

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_todo.yml +7 -0
  3. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  4. data/lib/DhanHQ/agent/policy.rb +51 -0
  5. data/lib/DhanHQ/agent/tool_registry.rb +250 -0
  6. data/lib/DhanHQ/agent.rb +12 -0
  7. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  8. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  9. data/lib/DhanHQ/ai.rb +27 -0
  10. data/lib/DhanHQ/auth.rb +0 -1
  11. data/lib/DhanHQ/client.rb +1 -3
  12. data/lib/DhanHQ/constants.rb +2 -0
  13. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  14. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  15. data/lib/DhanHQ/core/auth_api.rb +0 -1
  16. data/lib/DhanHQ/errors.rb +4 -0
  17. data/lib/DhanHQ/events/base.rb +203 -0
  18. data/lib/DhanHQ/events/bus.rb +158 -0
  19. data/lib/DhanHQ/events.rb +40 -0
  20. data/lib/DhanHQ/indicators.rb +283 -0
  21. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  22. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  23. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  24. data/lib/DhanHQ/market_data.rb +25 -0
  25. data/lib/DhanHQ/mcp/server.rb +72 -0
  26. data/lib/DhanHQ/mcp.rb +10 -0
  27. data/lib/DhanHQ/models/funds.rb +12 -0
  28. data/lib/DhanHQ/models/holding.rb +42 -0
  29. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  30. data/lib/DhanHQ/models/instrument.rb +36 -0
  31. data/lib/DhanHQ/models/order.rb +95 -0
  32. data/lib/DhanHQ/models/position.rb +66 -0
  33. data/lib/DhanHQ/models/search_result.rb +12 -0
  34. data/lib/DhanHQ/models/trade.rb +13 -0
  35. data/lib/DhanHQ/models/twap_order.rb +136 -0
  36. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  37. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  38. data/lib/DhanHQ/option_analytics.rb +36 -0
  39. data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
  40. data/lib/DhanHQ/resources/twap_orders.rb +61 -0
  41. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  42. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  43. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  44. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  45. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  46. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  47. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  48. data/lib/DhanHQ/risk/pipeline.rb +65 -0
  49. data/lib/DhanHQ/risk.rb +250 -0
  50. data/lib/DhanHQ/skills/base.rb +132 -0
  51. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
  52. data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
  53. data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
  54. data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
  55. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  56. data/lib/DhanHQ/skills/registry.rb +101 -0
  57. data/lib/DhanHQ/skills/workflow.rb +66 -0
  58. data/lib/DhanHQ/skills.rb +29 -0
  59. data/lib/DhanHQ/strategy/base.rb +189 -0
  60. data/lib/DhanHQ/strategy.rb +40 -0
  61. data/lib/DhanHQ/version.rb +1 -1
  62. data/lib/DhanHQ/ws/decoder.rb +57 -19
  63. data/lib/DhanHQ.rb +3 -0
  64. data/lib/dhan_hq/agent.rb +3 -0
  65. data/lib/dhan_hq/mcp.rb +3 -0
  66. data/lib/dhan_hq.rb +27 -2
  67. data/lib/ta/technical_analysis.rb +3 -1
  68. data/skills/dhanhq-ruby/SKILL.md +74 -0
  69. data/skills/dhanhq-ruby/references/market_data.md +3 -0
  70. data/skills/dhanhq-ruby/references/orders.md +7 -0
  71. metadata +59 -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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../agent"
5
+
6
+ module DhanHQ
7
+ module MCP
8
+ # Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
9
+ class Server
10
+ def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env)
11
+ @input = input
12
+ @output = output
13
+ @policy = policy
14
+ end
15
+
16
+ def run
17
+ @input.each_line { |line| handle_line(line) }
18
+ end
19
+
20
+ def handle_line(line)
21
+ 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)
25
+ end
26
+
27
+ private
28
+
29
+ def dispatch(method, params)
30
+ case method
31
+ when "initialize"
32
+ {
33
+ protocolVersion: "2024-11-05",
34
+ serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
35
+ capabilities: { tools: {} }
36
+ }
37
+ when "tools/list"
38
+ { tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
39
+ when "tools/call"
40
+ result = DhanHQ::Agent::ToolRegistry.execute(
41
+ params.fetch("name"),
42
+ params.fetch("arguments", {}),
43
+ policy: @policy
44
+ )
45
+ { content: [{ type: "text", text: JSON.pretty_generate(serialize(result)) }] }
46
+ else
47
+ raise ArgumentError, "Unsupported MCP method: #{method}"
48
+ end
49
+ end
50
+
51
+ def mcp_tool(tool)
52
+ { name: tool[:name], description: "[#{tool[:risk]}] #{tool[:description]}", inputSchema: tool[:input_schema] }
53
+ end
54
+
55
+ def serialize(value)
56
+ case value
57
+ when Array then value.map { |v| serialize(v) }
58
+ when Hash then value.transform_values { |v| serialize(v) }
59
+ else
60
+ value.respond_to?(:attributes) ? value.attributes : value
61
+ end
62
+ end
63
+
64
+ def respond(id, result, code: nil, message: nil)
65
+ payload = { jsonrpc: "2.0", id: id }
66
+ code ? payload[:error] = { code: code, message: message } : payload[:result] = result
67
+ @output.puts(JSON.generate(payload))
68
+ @output.flush
69
+ end
70
+ end
71
+ end
72
+ 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.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../contracts/iceberg_order_contract"
4
+
5
+ module DhanHQ
6
+ module Models
7
+ ##
8
+ # Model for creating and managing Iceberg Orders.
9
+ #
10
+ # Iceberg orders allow you to place a large order while revealing only a small
11
+ # "visible" portion (disclosed quantity) to the market at a time. The remaining
12
+ # quantity is hidden and filled as prior legs execute, reducing market impact
13
+ # and information leakage.
14
+ #
15
+ # @note **Static IP Whitelisting**: Iceberg order creation, modification, and
16
+ # cancellation APIs require Static IP whitelisting.
17
+ #
18
+ # @example Create an iceberg order
19
+ # order = DhanHQ::Models::IcebergOrder.create(
20
+ # dhan_client_id: "1000000003",
21
+ # transaction_type: "BUY",
22
+ # exchange_segment: "NSE_EQ",
23
+ # product_type: "INTRADAY",
24
+ # order_type: "LIMIT",
25
+ # validity: "DAY",
26
+ # security_id: "11536",
27
+ # quantity: 1000,
28
+ # price: 2450.50,
29
+ # iceberg_qty: 100,
30
+ # disclosed_quantity: 100
31
+ # )
32
+ # puts "Iceberg Order ID: #{order.order_id} - #{order.order_status}"
33
+ #
34
+ # @example Modify an iceberg order (change visible leg size)
35
+ # order = DhanHQ::Models::IcebergOrder.find(order_id)
36
+ # order.modify(
37
+ # dhan_client_id: "1000000003",
38
+ # order_id: order_id,
39
+ # price: 2440.0,
40
+ # iceberg_qty: 150
41
+ # )
42
+ #
43
+ # @example Cancel an iceberg order
44
+ # order = DhanHQ::Models::IcebergOrder.find(order_id)
45
+ # order.cancel
46
+ #
47
+ # @example List all iceberg orders for the day
48
+ # orders = DhanHQ::Models::IcebergOrder.all
49
+ #
50
+ class IcebergOrder < BaseModel
51
+ include Concerns::ApiResponseHandler
52
+
53
+ attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
54
+ :transaction_type, :exchange_segment, :product_type, :order_type,
55
+ :validity, :trading_symbol, :security_id, :quantity,
56
+ :remaining_quantity, :price, :trigger_price, :after_market_order,
57
+ :iceberg_qty, :disclosed_quantity, :leg_name,
58
+ :create_time, :update_time, :exchange_time, :drv_expiry_date,
59
+ :drv_option_type, :drv_strike_price,
60
+ :oms_error_code, :oms_error_description, :average_traded_price, :filled_qty
61
+
62
+ class << self
63
+ # @return [DhanHQ::Resources::IcebergOrders]
64
+ def resource
65
+ @resource ||= DhanHQ::Resources::IcebergOrders.new
66
+ end
67
+
68
+ # Retrieves all iceberg orders for the day.
69
+ #
70
+ # @return [Array<IcebergOrder>]
71
+ def all
72
+ response = resource.all
73
+ return [] unless response.is_a?(Array)
74
+
75
+ response.map { |o| new(o, skip_validation: true) }
76
+ end
77
+
78
+ # Retrieves a specific iceberg order by ID.
79
+ #
80
+ # @param order_id [String]
81
+ # @return [IcebergOrder, nil]
82
+ def find(order_id)
83
+ response = resource.find(order_id)
84
+ return nil unless response.is_a?(Hash) && response.any?
85
+
86
+ new(response, skip_validation: true)
87
+ end
88
+
89
+ # Creates a new iceberg order.
90
+ #
91
+ # @param params [Hash{Symbol => String, Integer, Float}]
92
+ # @return [IcebergOrder, nil]
93
+ def create(params)
94
+ normalized = snake_case(params)
95
+ config = DhanHQ.configuration
96
+ normalized[:dhan_client_id] ||= config.client_id if config&.client_id
97
+ validate_params!(normalized, DhanHQ::Contracts::IcebergOrderCreateContract)
98
+ formatted = camelize_keys(normalized)
99
+ response = resource.create(formatted)
100
+ return nil unless response.is_a?(Hash) && response["orderId"]
101
+
102
+ new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
103
+ end
104
+ end
105
+
106
+ # Modifies an existing iceberg order.
107
+ #
108
+ # @param new_params [Hash{Symbol => String, Integer, Float}]
109
+ # @return [IcebergOrder, nil]
110
+ def modify(new_params)
111
+ raise "Order ID is required to modify an iceberg order" unless order_id
112
+
113
+ DhanHQ.logger&.info("[DhanHQ::Models::IcebergOrder] Modifying order #{order_id}")
114
+ full_params = snake_case(new_params)
115
+ config = DhanHQ.configuration
116
+ full_params[:dhan_client_id] ||= config.client_id if config&.client_id
117
+ full_params[:order_id] = order_id
118
+ validate_params!(full_params, DhanHQ::Contracts::IcebergOrderModifyContract)
119
+ formatted = camelize_keys(full_params)
120
+ response = self.class.resource.update(order_id, formatted)
121
+ success = handle_api_response(response, success_key: "orderId",
122
+ context: "[DhanHQ::Models::IcebergOrder] Modification")
123
+ return self.class.find(order_id) if success
124
+
125
+ nil
126
+ end
127
+
128
+ # Cancels the iceberg order.
129
+ #
130
+ # @return [Boolean] true when cancelled successfully
131
+ def cancel
132
+ raise "Order ID is required to cancel an iceberg order" unless order_id
133
+
134
+ response = self.class.resource.cancel(order_id)
135
+ response.is_a?(Hash) && response["orderStatus"] == DhanHQ::Constants::OrderStatus::CANCELLED
136
+ end
137
+ end
138
+ end
139
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../contracts/instrument_list_contract"
4
4
  require_relative "instrument_helpers"
5
+ require_relative "search_result"
5
6
 
6
7
  module DhanHQ
7
8
  module Models
@@ -114,6 +115,41 @@ module DhanHQ
114
115
  nil
115
116
  end
116
117
 
118
+ # Search instruments across segments and return agent-friendly search results.
119
+ # @param query [String] symbol/company/index text
120
+ # @param options [Hash] :segments, :limit, :exact_match, :case_sensitive
121
+ # @return [Array<DhanHQ::Models::SearchResult>]
122
+ def search(query, options = {})
123
+ raise ArgumentError, "query is required" if query.to_s.strip.empty?
124
+
125
+ limit = Integer(options.fetch(:limit, 20))
126
+ segments = options[:segments] || %w[NSE_EQ BSE_EQ IDX_I NSE_FNO BSE_FNO NSE_CURRENCY BSE_CURRENCY MCX_COMM]
127
+ exact_match = options.fetch(:exact_match, false)
128
+ case_sensitive = options.fetch(:case_sensitive, false)
129
+ needle = case_sensitive ? query.to_s : query.to_s.upcase
130
+
131
+ results = []
132
+ segments.each do |segment|
133
+ by_segment(segment).each do |instrument|
134
+ haystacks = [
135
+ instrument.symbol_name,
136
+ instrument.display_name,
137
+ instrument.underlying_symbol,
138
+ instrument.isin
139
+ ].compact
140
+ matched = haystacks.any? do |value|
141
+ comparable = case_sensitive ? value.to_s : value.to_s.upcase
142
+ exact_match ? comparable == needle : comparable.include?(needle)
143
+ end
144
+ next unless matched
145
+
146
+ results << DhanHQ::Models::SearchResult.new(instrument.attributes, skip_validation: true)
147
+ return results if results.length >= limit
148
+ end
149
+ end
150
+ results
151
+ end
152
+
117
153
  def normalize_csv_row(row)
118
154
  # Extract exchange and segment from CSV
119
155
  exchange_id = row["EXCH_ID"] || row["EXCHANGE"]
@@ -73,6 +73,101 @@ module DhanHQ
73
73
  :drv_strike_price, :oms_error_code, :oms_error_description, :algo_id,
74
74
  :remaining_quantity, :average_traded_price, :filled_qty
75
75
 
76
+ # Returns a concise prompt-friendly summary of the order.
77
+ # Useful for AI agents to understand order state without raw attributes.
78
+ def to_prompt
79
+ parts = [
80
+ "#{transaction_type} #{quantity}x #{trading_symbol || security_id}",
81
+ "on #{exchange_segment}",
82
+ "as #{order_type}/#{product_type}",
83
+ "status=#{order_status}"
84
+ ]
85
+ parts << "price=#{price}" if price&.to_f&.positive?
86
+ parts << "trigger=#{trigger_price}" if trigger_price&.to_f&.positive?
87
+ parts << "filled=#{filled_qty}" if filled_qty&.positive?
88
+ parts << "remaining=#{remaining_quantity}" if remaining_quantity&.positive?
89
+ parts << "id=#{order_id}" if order_id
90
+ parts << "correlation=#{correlation_id}" if correlation_id
91
+ parts.join(", ")
92
+ end
93
+
94
+ # Returns true if the order has been fully filled.
95
+ def filled?
96
+ order_status == DhanHQ::Constants::OrderStatus::TRADED
97
+ end
98
+
99
+ # Returns true if the order is pending execution.
100
+ def pending?
101
+ order_status == DhanHQ::Constants::OrderStatus::PENDING
102
+ end
103
+
104
+ # Returns true if the order was rejected by the exchange.
105
+ def rejected?
106
+ order_status == DhanHQ::Constants::OrderStatus::REJECTED
107
+ end
108
+
109
+ # Returns true if the order was cancelled.
110
+ def cancelled?
111
+ order_status == DhanHQ::Constants::OrderStatus::CANCELLED
112
+ end
113
+
114
+ # Returns true if the order is in transit to the exchange.
115
+ def transit?
116
+ order_status == DhanHQ::Constants::OrderStatus::TRANSIT
117
+ end
118
+
119
+ # Returns true if the order is partially filled.
120
+ def partially_filled?
121
+ filled_qty.to_i.positive? && remaining_quantity.to_i.positive?
122
+ end
123
+
124
+ # Returns the fill percentage (0.0 to 100.0).
125
+ def fill_percentage
126
+ return 0.0 if quantity.to_i.zero?
127
+
128
+ (filled_qty.to_i.to_f / quantity.to_i * 100).round(2)
129
+ end
130
+
131
+ # Returns true if this is a buy order.
132
+ def buy?
133
+ transaction_type == DhanHQ::Constants::TransactionType::BUY
134
+ end
135
+
136
+ # Returns true if this is a sell order.
137
+ def sell?
138
+ transaction_type == DhanHQ::Constants::TransactionType::SELL
139
+ end
140
+
141
+ # Returns true if this is an intraday order.
142
+ def intraday?
143
+ product_type == DhanHQ::Constants::ProductType::INTRADAY
144
+ end
145
+
146
+ # Returns true if this is a delivery (CNC) order.
147
+ def delivery?
148
+ product_type == DhanHQ::Constants::ProductType::CNC
149
+ end
150
+
151
+ # Returns true if this is an MTF order.
152
+ def mtf?
153
+ product_type == DhanHQ::Constants::ProductType::MTF
154
+ end
155
+
156
+ # Returns true if this is a market order.
157
+ def market_order?
158
+ order_type == DhanHQ::Constants::OrderType::MARKET
159
+ end
160
+
161
+ # Returns true if this is a limit order.
162
+ def limit_order?
163
+ order_type == DhanHQ::Constants::OrderType::LIMIT
164
+ end
165
+
166
+ # Returns true if this is a stop-loss order.
167
+ def stop_loss_order?
168
+ order_type&.start_with?(DhanHQ::Constants::OrderType::STOP_LOSS)
169
+ end
170
+
76
171
  class << self
77
172
  ##
78
173
  # Provides a shared instance of the Orders resource.