DhanHQ 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +51 -0
  5. data/README.md +152 -4
  6. data/docs/CONSTANTS_REFERENCE.md +3 -2
  7. data/exe/dhanhq-mcp +7 -0
  8. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  9. data/lib/DhanHQ/agent/policy.rb +51 -0
  10. data/lib/DhanHQ/agent/tool_registry.rb +299 -0
  11. data/lib/DhanHQ/agent.rb +12 -0
  12. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  13. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  14. data/lib/DhanHQ/ai.rb +27 -0
  15. data/lib/DhanHQ/auth.rb +0 -1
  16. data/lib/DhanHQ/client.rb +1 -3
  17. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  18. data/lib/DhanHQ/constants.rb +5 -2
  19. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  20. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  21. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  22. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  23. data/lib/DhanHQ/core/auth_api.rb +0 -1
  24. data/lib/DhanHQ/errors.rb +4 -0
  25. data/lib/DhanHQ/events/base.rb +203 -0
  26. data/lib/DhanHQ/events/bus.rb +158 -0
  27. data/lib/DhanHQ/events.rb +40 -0
  28. data/lib/DhanHQ/indicators.rb +283 -0
  29. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  30. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  31. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  32. data/lib/DhanHQ/market_data.rb +25 -0
  33. data/lib/DhanHQ/mcp/server.rb +235 -0
  34. data/lib/DhanHQ/mcp.rb +10 -0
  35. data/lib/DhanHQ/models/funds.rb +12 -0
  36. data/lib/DhanHQ/models/holding.rb +42 -0
  37. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  38. data/lib/DhanHQ/models/instrument.rb +80 -14
  39. data/lib/DhanHQ/models/order.rb +95 -0
  40. data/lib/DhanHQ/models/position.rb +66 -0
  41. data/lib/DhanHQ/models/search_result.rb +12 -0
  42. data/lib/DhanHQ/models/trade.rb +13 -0
  43. data/lib/DhanHQ/models/twap_order.rb +136 -0
  44. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  45. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  46. data/lib/DhanHQ/option_analytics.rb +36 -0
  47. data/lib/DhanHQ/rate_limiter.rb +5 -3
  48. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  49. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  50. data/lib/DhanHQ/resources/iceberg_orders.rb +62 -0
  51. data/lib/DhanHQ/resources/orders.rb +2 -0
  52. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  53. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  54. data/lib/DhanHQ/resources/twap_orders.rb +62 -0
  55. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  56. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  57. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  58. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  59. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  60. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  61. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  62. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  63. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  64. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  65. data/lib/DhanHQ/risk/pipeline.rb +72 -0
  66. data/lib/DhanHQ/risk.rb +250 -0
  67. data/lib/DhanHQ/skills/base.rb +183 -0
  68. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  69. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  70. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +84 -0
  71. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  72. data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
  73. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  74. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  75. data/lib/DhanHQ/skills/builtin/square_off_all.rb +42 -0
  76. data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
  77. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  78. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  79. data/lib/DhanHQ/skills/registry.rb +101 -0
  80. data/lib/DhanHQ/skills/workflow.rb +66 -0
  81. data/lib/DhanHQ/skills.rb +29 -0
  82. data/lib/DhanHQ/strategy/base.rb +189 -0
  83. data/lib/DhanHQ/strategy.rb +40 -0
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/ws/decoder.rb +57 -19
  86. data/lib/DhanHQ.rb +3 -0
  87. data/lib/dhan_hq/agent.rb +3 -0
  88. data/lib/dhan_hq/mcp.rb +3 -0
  89. data/lib/dhan_hq.rb +74 -2
  90. data/lib/ta/technical_analysis.rb +3 -1
  91. data/skills/dhanhq-ruby/SKILL.md +207 -0
  92. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  93. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  94. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  95. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  96. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  97. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  98. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  99. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  100. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  101. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  102. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  103. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  104. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  105. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  106. data/skills/dhanhq-ruby/references/funds.md +67 -0
  107. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  108. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  109. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  110. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  111. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  112. data/skills/dhanhq-ruby/references/orders.md +201 -0
  113. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  114. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  115. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  116. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  117. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  118. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  119. metadata +95 -20
@@ -0,0 +1,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
@@ -62,25 +63,55 @@ module DhanHQ
62
63
  exact_match = options[:exact_match] || false
63
64
  case_sensitive = options[:case_sensitive] || false
64
65
 
65
- instruments = by_segment(exchange_segment)
66
- 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?
67
68
 
69
+ require "csv"
68
70
  search_symbol = case_sensitive ? symbol : symbol.upcase
69
71
 
70
- 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|
71
76
  # For equity instruments, prefer underlying_symbol over symbol_name
72
- instrument_symbol = if instrument.instrument == DhanHQ::Constants::InstrumentType::EQUITY && instrument.underlying_symbol
73
- case_sensitive ? instrument.underlying_symbol : instrument.underlying_symbol.upcase
74
- else
75
- case_sensitive ? instrument.symbol_name : instrument.symbol_name.upcase
76
- end
77
-
78
- if exact_match
79
- instrument_symbol == search_symbol
80
- else
81
- instrument_symbol.include?(search_symbol)
82
- 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)
83
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)
84
115
  end
85
116
 
86
117
  # Find a specific instrument across all exchange segments.
@@ -114,6 +145,41 @@ module DhanHQ
114
145
  nil
115
146
  end
116
147
 
148
+ # Search instruments across segments and return agent-friendly search results.
149
+ # @param query [String] symbol/company/index text
150
+ # @param options [Hash] :segments, :limit, :exact_match, :case_sensitive
151
+ # @return [Array<DhanHQ::Models::SearchResult>]
152
+ def search(query, options = {})
153
+ raise ArgumentError, "query is required" if query.to_s.strip.empty?
154
+
155
+ limit = Integer(options.fetch(:limit, 20))
156
+ segments = options[:segments] || %w[NSE_EQ BSE_EQ IDX_I NSE_FNO BSE_FNO NSE_CURRENCY BSE_CURRENCY MCX_COMM]
157
+ exact_match = options.fetch(:exact_match, false)
158
+ case_sensitive = options.fetch(:case_sensitive, false)
159
+ needle = case_sensitive ? query.to_s : query.to_s.upcase
160
+
161
+ results = []
162
+ segments.each do |segment|
163
+ by_segment(segment).each do |instrument|
164
+ haystacks = [
165
+ instrument.symbol_name,
166
+ instrument.display_name,
167
+ instrument.underlying_symbol,
168
+ instrument.isin
169
+ ].compact
170
+ matched = haystacks.any? do |value|
171
+ comparable = case_sensitive ? value.to_s : value.to_s.upcase
172
+ exact_match ? comparable == needle : comparable.include?(needle)
173
+ end
174
+ next unless matched
175
+
176
+ results << DhanHQ::Models::SearchResult.new(instrument.attributes, skip_validation: true)
177
+ return results if results.length >= limit
178
+ end
179
+ end
180
+ results
181
+ end
182
+
117
183
  def normalize_csv_row(row)
118
184
  # Extract exchange and segment from CSV
119
185
  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.
@@ -44,6 +44,72 @@ module DhanHQ
44
44
  :day_sell_value, :drv_expiry_date, :drv_option_type, :drv_strike_price,
45
45
  :cross_currency
46
46
 
47
+ # Returns a concise prompt-friendly summary of the position.
48
+ def to_prompt
49
+ parts = [
50
+ "#{position_type} #{net_qty}x #{trading_symbol || security_id}",
51
+ "on #{exchange_segment}/#{product_type}"
52
+ ]
53
+ parts << "buy_avg=#{buy_avg}" if buy_avg&.positive?
54
+ parts << "sell_avg=#{sell_avg}" if sell_avg&.positive?
55
+ parts << "realized_pnl=#{realized_profit}" if realized_profit
56
+ parts << "unrealized_pnl=#{unrealized_profit}" if unrealized_profit
57
+ parts << "expiry=#{drv_expiry_date}" if drv_expiry_date
58
+ parts << "strike=#{drv_strike_price}" if drv_strike_price&.positive?
59
+ parts << "type=#{drv_option_type}" if drv_option_type
60
+ parts.join(", ")
61
+ end
62
+
63
+ # Returns true if this is a long position.
64
+ def long?
65
+ position_type == DhanHQ::Constants::PositionType::LONG
66
+ end
67
+
68
+ # Returns true if this is a short position.
69
+ def short?
70
+ position_type == DhanHQ::Constants::PositionType::SHORT
71
+ end
72
+
73
+ # Returns true if this position is closed.
74
+ def closed?
75
+ position_type == DhanHQ::Constants::OrderStatus::CLOSED || net_qty.to_i.zero?
76
+ end
77
+
78
+ # Returns true if this position has an open quantity.
79
+ def open?
80
+ !closed? && net_qty.to_i != 0
81
+ end
82
+
83
+ # Returns true if this position is profitable (unrealized).
84
+ def profitable?
85
+ unrealized_profit.to_f.positive?
86
+ end
87
+
88
+ # Returns true if this position is in loss (unrealized).
89
+ def in_loss?
90
+ unrealized_profit.to_f.negative?
91
+ end
92
+
93
+ # Returns the total unrealized P&L including realized.
94
+ def total_pnl
95
+ realized_profit.to_f + unrealized_profit.to_f
96
+ end
97
+
98
+ # Returns true if this is an F&O position.
99
+ def derivatives?
100
+ drv_expiry_date.present? || drv_strike_price.to_f.positive?
101
+ end
102
+
103
+ # Returns true if this is an options position.
104
+ def options?
105
+ derivatives? && drv_option_type.present?
106
+ end
107
+
108
+ # Returns true if this is a futures position.
109
+ def futures?
110
+ derivatives? && drv_option_type.nil?
111
+ end
112
+
47
113
  class << self
48
114
  ##
49
115
  # Provides a shared instance of the Positions resource.
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ # Lightweight result returned by Instrument.search for agent-friendly security resolution.
6
+ class SearchResult < BaseModel
7
+ attributes :security_id, :symbol_name, :display_name, :exchange_segment, :instrument,
8
+ :instrument_type, :lot_size, :tick_size, :expiry_date, :strike_price,
9
+ :option_type, :underlying_symbol, :isin
10
+ end
11
+ end
12
+ end
@@ -44,6 +44,19 @@ module DhanHQ
44
44
  :create_time, :update_time, :exchange_time, :drv_expiry_date,
45
45
  :drv_option_type, :drv_strike_price
46
46
 
47
+ # Returns a concise prompt-friendly summary of the trade.
48
+ def to_prompt
49
+ parts = [
50
+ "#{transaction_type} #{traded_quantity}x #{trading_symbol || security_id}",
51
+ "@ ₹#{traded_price}",
52
+ "on #{exchange_segment}/#{product_type}"
53
+ ]
54
+ parts << "order=#{order_id}" if order_id
55
+ parts << "charges=₹#{total_charges}" if respond_to?(:total_charges) && total_charges
56
+ parts << "time=#{create_time}" if create_time
57
+ parts.join(", ")
58
+ end
59
+
47
60
  class << self
48
61
  ##
49
62
  # Provides a shared instance of the Trades resource for current day tradebook APIs.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../contracts/twap_order_contract"
4
+
5
+ module DhanHQ
6
+ module Models
7
+ ##
8
+ # Model for creating and managing TWAP Orders.
9
+ #
10
+ # TWAP (Time-Weighted Average Price) orders split a large order into smaller
11
+ # slices distributed evenly across a defined time window. This minimizes market
12
+ # impact and achieves execution closer to the period's average price.
13
+ #
14
+ # @note **Static IP Whitelisting**: TWAP order creation, modification, and
15
+ # cancellation APIs require Static IP whitelisting.
16
+ #
17
+ # @example Create a TWAP order
18
+ # order = DhanHQ::Models::TwapOrder.create(
19
+ # dhan_client_id: "1000000003",
20
+ # transaction_type: "SELL",
21
+ # exchange_segment: "NSE_EQ",
22
+ # product_type: "INTRADAY",
23
+ # order_type: "MARKET",
24
+ # validity: "DAY",
25
+ # security_id: "11536",
26
+ # quantity: 500,
27
+ # price: 0, # MARKET order
28
+ # slice_interval: 300, # 5 minutes
29
+ # start_time: "09:30:00",
30
+ # end_time: "15:00:00"
31
+ # )
32
+ # puts "TWAP Order ID: #{order.order_id} - #{order.order_status}"
33
+ #
34
+ # @example Modify a TWAP order (extend window)
35
+ # order = DhanHQ::Models::TwapOrder.find(order_id)
36
+ # order.modify(
37
+ # dhan_client_id: "1000000003",
38
+ # order_id: order_id,
39
+ # end_time: "15:30:00",
40
+ # slice_interval: 600
41
+ # )
42
+ #
43
+ # @example Cancel a TWAP order
44
+ # order = DhanHQ::Models::TwapOrder.find(order_id)
45
+ # order.cancel
46
+ #
47
+ class TwapOrder < BaseModel
48
+ include Concerns::ApiResponseHandler
49
+
50
+ attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
51
+ :transaction_type, :exchange_segment, :product_type, :order_type,
52
+ :validity, :trading_symbol, :security_id, :quantity,
53
+ :remaining_quantity, :price, :trigger_price, :after_market_order,
54
+ :slice_interval, :start_time, :end_time, :leg_name,
55
+ :create_time, :update_time, :exchange_time, :drv_expiry_date,
56
+ :drv_option_type, :drv_strike_price,
57
+ :oms_error_code, :oms_error_description, :average_traded_price, :filled_qty
58
+
59
+ class << self
60
+ # @return [DhanHQ::Resources::TwapOrders]
61
+ def resource
62
+ @resource ||= DhanHQ::Resources::TwapOrders.new
63
+ end
64
+
65
+ # Retrieves all TWAP orders for the day.
66
+ #
67
+ # @return [Array<TwapOrder>]
68
+ def all
69
+ response = resource.all
70
+ return [] unless response.is_a?(Array)
71
+
72
+ response.map { |o| new(o, skip_validation: true) }
73
+ end
74
+
75
+ # Retrieves a specific TWAP order by ID.
76
+ #
77
+ # @param order_id [String]
78
+ # @return [TwapOrder, nil]
79
+ def find(order_id)
80
+ response = resource.find(order_id)
81
+ return nil unless response.is_a?(Hash) && response.any?
82
+
83
+ new(response, skip_validation: true)
84
+ end
85
+
86
+ # Creates a new TWAP order.
87
+ #
88
+ # @param params [Hash{Symbol => String, Integer, Float}]
89
+ # @return [TwapOrder, nil]
90
+ def create(params)
91
+ normalized = snake_case(params)
92
+ config = DhanHQ.configuration
93
+ normalized[:dhan_client_id] ||= config.client_id if config&.client_id
94
+ validate_params!(normalized, DhanHQ::Contracts::TwapOrderCreateContract)
95
+ formatted = camelize_keys(normalized)
96
+ response = resource.create(formatted)
97
+ return nil unless response.is_a?(Hash) && response["orderId"]
98
+
99
+ new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
100
+ end
101
+ end
102
+
103
+ # Modifies an existing TWAP order.
104
+ #
105
+ # @param new_params [Hash{Symbol => String, Integer, Float}]
106
+ # @return [TwapOrder, nil]
107
+ def modify(new_params)
108
+ raise "Order ID is required to modify a TWAP order" unless order_id
109
+
110
+ DhanHQ.logger&.info("[DhanHQ::Models::TwapOrder] Modifying order #{order_id}")
111
+ full_params = snake_case(new_params)
112
+ config = DhanHQ.configuration
113
+ full_params[:dhan_client_id] ||= config.client_id if config&.client_id
114
+ full_params[:order_id] = order_id
115
+ validate_params!(full_params, DhanHQ::Contracts::TwapOrderModifyContract)
116
+ formatted = camelize_keys(full_params)
117
+ response = self.class.resource.update(order_id, formatted)
118
+ success = handle_api_response(response, success_key: "orderId",
119
+ context: "[DhanHQ::Models::TwapOrder] Modification")
120
+ return self.class.find(order_id) if success
121
+
122
+ nil
123
+ end
124
+
125
+ # Cancels the TWAP order.
126
+ #
127
+ # @return [Boolean] true when cancelled successfully
128
+ def cancel
129
+ raise "Order ID is required to cancel a TWAP order" unless order_id
130
+
131
+ response = self.class.resource.cancel(order_id)
132
+ response.is_a?(Hash) && response["orderStatus"] == DhanHQ::Constants::OrderStatus::CANCELLED
133
+ end
134
+ end
135
+ end
136
+ end