DhanHQ 2.4.0 → 2.6.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/CHANGELOG.md +103 -7
  4. data/GUIDE.md +57 -39
  5. data/README.md +198 -755
  6. data/docs/API_DOCS_GAPS.md +128 -0
  7. data/docs/API_VERIFICATION.md +10 -11
  8. data/{README1.md → docs/ARCHIVE_README.md} +16 -16
  9. data/docs/AUTHENTICATION.md +72 -10
  10. data/docs/CONFIGURATION.md +109 -0
  11. data/docs/CONSTANTS_REFERENCE.md +477 -0
  12. data/docs/DATA_API_PARAMETERS.md +7 -7
  13. data/docs/{rails_websocket_integration.md → RAILS_WEBSOCKET_INTEGRATION.md} +10 -10
  14. data/docs/{standalone_ruby_websocket_integration.md → STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md} +32 -32
  15. data/docs/SUPER_ORDERS.md +284 -0
  16. data/docs/{technical_analysis.md → TECHNICAL_ANALYSIS.md} +3 -3
  17. data/docs/TESTING_GUIDE.md +84 -82
  18. data/docs/TROUBLESHOOTING.md +117 -0
  19. data/docs/{websocket_integration.md → WEBSOCKET_INTEGRATION.md} +19 -19
  20. data/docs/WEBSOCKET_PROTOCOL.md +154 -0
  21. data/lib/DhanHQ/constants.rb +456 -151
  22. data/lib/DhanHQ/contracts/alert_order_contract.rb +37 -10
  23. data/lib/DhanHQ/contracts/base_contract.rb +22 -0
  24. data/lib/DhanHQ/contracts/edis_contract.rb +25 -0
  25. data/lib/DhanHQ/contracts/margin_calculator_contract.rb +27 -4
  26. data/lib/DhanHQ/contracts/modify_order_contract.rb +65 -12
  27. data/lib/DhanHQ/contracts/multi_scrip_margin_calc_request_contract.rb +23 -0
  28. data/lib/DhanHQ/contracts/order_contract.rb +171 -39
  29. data/lib/DhanHQ/contracts/place_order_contract.rb +14 -141
  30. data/lib/DhanHQ/contracts/pnl_based_exit_contract.rb +20 -0
  31. data/lib/DhanHQ/contracts/position_conversion_contract.rb +15 -3
  32. data/lib/DhanHQ/contracts/slice_order_contract.rb +10 -1
  33. data/lib/DhanHQ/contracts/user_ip_contract.rb +14 -0
  34. data/lib/DhanHQ/core/base_model.rb +13 -4
  35. data/lib/DhanHQ/helpers/response_helper.rb +2 -2
  36. data/lib/DhanHQ/helpers/validation_helper.rb +1 -1
  37. data/lib/DhanHQ/models/alert_order.rb +29 -11
  38. data/lib/DhanHQ/models/concerns/api_response_handler.rb +46 -0
  39. data/lib/DhanHQ/models/edis.rb +101 -0
  40. data/lib/DhanHQ/models/expired_options_data.rb +6 -12
  41. data/lib/DhanHQ/models/forever_order.rb +8 -5
  42. data/lib/DhanHQ/models/historical_data.rb +0 -8
  43. data/lib/DhanHQ/models/instrument.rb +1 -7
  44. data/lib/DhanHQ/models/instrument_helpers.rb +4 -4
  45. data/lib/DhanHQ/models/kill_switch.rb +23 -11
  46. data/lib/DhanHQ/models/margin.rb +51 -2
  47. data/lib/DhanHQ/models/order.rb +107 -126
  48. data/lib/DhanHQ/models/order_update.rb +7 -13
  49. data/lib/DhanHQ/models/pnl_exit.rb +122 -0
  50. data/lib/DhanHQ/models/position.rb +23 -1
  51. data/lib/DhanHQ/models/postback.rb +114 -0
  52. data/lib/DhanHQ/models/profile.rb +0 -10
  53. data/lib/DhanHQ/models/super_order.rb +13 -3
  54. data/lib/DhanHQ/models/trade.rb +11 -23
  55. data/lib/DhanHQ/resources/ip_setup.rb +16 -5
  56. data/lib/DhanHQ/resources/kill_switch.rb +17 -7
  57. data/lib/DhanHQ/resources/margin_calculator.rb +9 -0
  58. data/lib/DhanHQ/resources/orders.rb +41 -41
  59. data/lib/DhanHQ/resources/pnl_exit.rb +37 -0
  60. data/lib/DhanHQ/resources/positions.rb +8 -0
  61. data/lib/DhanHQ/version.rb +1 -1
  62. data/lib/DhanHQ/ws/cmd_bus.rb +1 -1
  63. data/lib/DhanHQ/ws/orders/client.rb +6 -6
  64. data/lib/DhanHQ/ws/singleton_lock.rb +2 -1
  65. data/lib/dhanhq/analysis/options_buying_advisor.rb +2 -2
  66. data/lib/rubocop/cop/dhanhq/use_constants.rb +171 -0
  67. metadata +29 -24
  68. data/TODO-1.md +0 -14
  69. data/TODO.md +0 -127
  70. data/app/services/live/order_update_guard_support.rb +0 -75
  71. data/app/services/live/order_update_hub.rb +0 -76
  72. data/app/services/live/order_update_persistence_support.rb +0 -68
  73. data/docs/PR_2.2.0.md +0 -48
  74. data/examples/comprehensive_websocket_examples.rb +0 -148
  75. data/examples/instrument_finder_test.rb +0 -195
  76. data/examples/live_order_updates.rb +0 -118
  77. data/examples/market_depth_example.rb +0 -144
  78. data/examples/market_feed_example.rb +0 -81
  79. data/examples/order_update_example.rb +0 -105
  80. data/examples/trading_fields_example.rb +0 -215
  81. /data/docs/{live_order_updates.md → LIVE_ORDER_UPDATES.md} +0 -0
  82. /data/docs/{rails_integration.md → RAILS_INTEGRATION.md} +0 -0
@@ -1,168 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "order_contract"
4
+
3
5
  module DhanHQ
4
6
  module Contracts
5
7
  # Validation contract for placing an order via Dhanhq's API.
6
- #
7
- # This contract validates the parameters required to place an order,
8
- # ensuring the correctness of inputs based on API requirements. It includes:
9
- # - Mandatory fields for order placement.
10
- # - Conditional validation for optional fields based on provided values.
11
- # - Validation of enumerated values using constants for consistency.
12
- #
13
- # Example usage:
14
- # contract = Dhanhq::Contracts::PlaceOrderContract.new
15
- # result = contract.call(
16
- # dhanClientId: "123456",
17
- # transaction_type: "BUY",
18
- # exchange_segment: "NSE_EQ",
19
- # product_type: "CNC",
20
- # order_type: "LIMIT",
21
- # validity: "DAY",
22
- # security_id: "1001",
23
- # quantity: 10,
24
- # price: 150.0
25
- # )
26
- # result.success? # => true or false
27
- #
28
- # @see https://dhanhq.co/docs/v2/ Dhanhq API Documentation
29
- class PlaceOrderContract < BaseContract
30
- # Parameters and validation rules for the place order request.
31
- #
32
- # @!attribute [r] correlation_id
33
- # @return [String] Optional. Identifier for tracking, max length 25 characters.
34
- # @!attribute [r] transaction_type
35
- # @return [String] Required. BUY or SELL.
36
- # @!attribute [r] exchange_segment
37
- # @return [String] Required. Exchange segment for the order.
38
- # Must be one of: `EXCHANGE_SEGMENTS`.
39
- # @!attribute [r] product_type
40
- # @return [String] Required. Product type for the order.
41
- # Must be one of: `PRODUCT_TYPES`.
42
- # @!attribute [r] order_type
43
- # @return [String] Required. Type of order.
44
- # Must be one of: `ORDER_TYPES`.
45
- # @!attribute [r] validity
46
- # @return [String] Required. Validity of the order.
47
- # Must be one of: DAY, IOC.
48
- # @!attribute [r] trading_symbol
49
- # @return [String] Optional. Trading symbol of the instrument.
50
- # @!attribute [r] security_id
51
- # @return [String] Required. Security identifier for the order.
52
- # @!attribute [r] quantity
53
- # @return [Integer] Required. Quantity of the order, must be greater than 0.
54
- # @!attribute [r] disclosed_quantity
55
- # @return [Integer] Optional. Disclosed quantity, must be >= 0 if provided.
56
- # @!attribute [r] price
57
- # @return [Float] Optional. Price for the order, must be > 0 if provided.
58
- # @!attribute [r] trigger_price
59
- # @return [Float] Optional. Trigger price for stop-loss orders, must be > 0 if provided.
60
- # @!attribute [r] after_market_order
61
- # @return [Boolean] Optional. Indicates if this is an after-market order.
62
- # @!attribute [r] amo_time
63
- # @return [String] Optional. Time for after-market orders. Must be one of: OPEN, OPEN_30, OPEN_60.
64
- # @!attribute [r] bo_profit_value
65
- # @return [Float] Optional. Profit value for Bracket Orders, must be > 0 if provided.
66
- # @!attribute [r] bo_stop_loss_value
67
- # @return [Float] Optional. Stop-loss value for Bracket Orders, must be > 0 if provided.
68
- # @!attribute [r] drv_expiry_date
69
- # @return [String] Optional. Expiry date for derivative contracts.
70
- # @!attribute [r] drv_option_type
71
- # @return [String] Optional. Option type for derivatives, must be one of: CALL, PUT, NA.
72
- # @!attribute [r] drv_strike_price
73
- # @return [Float] Optional. Strike price for options, must be > 0 if provided.
8
+ class PlaceOrderContract < OrderContract
74
9
  params do
10
+ # Common required fields
75
11
  required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
76
12
  required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
77
13
  required(:product_type).filled(:string, included_in?: PRODUCT_TYPES)
78
14
  required(:order_type).filled(:string, included_in?: ORDER_TYPES)
79
15
  required(:validity).filled(:string, included_in?: VALIDITY_TYPES)
80
- required(:security_id).filled(:string)
16
+ required(:security_id).filled(:string, max_size?: 20)
81
17
  required(:quantity).filled(:integer, gt?: 0)
18
+
19
+ # Optional fields
20
+ optional(:correlation_id).maybe(:string, max_size?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
82
21
  optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
83
- optional(:trading_symbol).maybe(:string)
84
- optional(:correlation_id).maybe(:string, max_size?: 25)
85
22
  optional(:price).maybe(:float, gt?: 0)
86
23
  optional(:trigger_price).maybe(:float, gt?: 0)
87
24
  optional(:after_market_order).maybe(:bool)
88
- optional(:amo_time).maybe(:string, included_in?: %w[OPEN OPEN_30 OPEN_60])
25
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMES)
89
26
  optional(:bo_profit_value).maybe(:float, gt?: 0)
90
27
  optional(:bo_stop_loss_value).maybe(:float, gt?: 0)
28
+
29
+ # Derivative specific fields
30
+ optional(:trading_symbol).maybe(:string)
91
31
  optional(:drv_expiry_date).maybe(:string)
92
32
  optional(:drv_option_type).maybe(:string, included_in?: %w[CALL PUT NA])
93
33
  optional(:drv_strike_price).maybe(:float, gt?: 0)
94
34
  end
95
35
 
96
- # Validate that float values are finite (not NaN or Infinity) and within reasonable bounds
97
- rule(:price) do
98
- if values[:price].is_a?(Float)
99
- if values[:price].nan? || values[:price].infinite?
100
- key(:price).failure("must be a finite number")
101
- elsif values[:price] > 1_000_000_000
102
- key(:price).failure("must be less than 1,000,000,000")
103
- end
104
- end
105
- end
106
-
107
- rule(:trigger_price) do
108
- if values[:trigger_price].is_a?(Float)
109
- if values[:trigger_price].nan? || values[:trigger_price].infinite?
110
- key(:trigger_price).failure("must be a finite number")
111
- elsif values[:trigger_price] > 1_000_000_000
112
- key(:trigger_price).failure("must be less than 1,000,000,000")
113
- end
114
- end
115
- end
116
-
117
- rule(:bo_profit_value) do
118
- if values[:bo_profit_value].is_a?(Float)
119
- if values[:bo_profit_value].nan? || values[:bo_profit_value].infinite?
120
- key(:bo_profit_value).failure("must be a finite number")
121
- elsif values[:bo_profit_value] > 1_000_000_000
122
- key(:bo_profit_value).failure("must be less than 1,000,000,000")
123
- end
124
- end
125
- end
126
-
127
- rule(:bo_stop_loss_value) do
128
- if values[:bo_stop_loss_value].is_a?(Float)
129
- if values[:bo_stop_loss_value].nan? || values[:bo_stop_loss_value].infinite?
130
- key(:bo_stop_loss_value).failure("must be a finite number")
131
- elsif values[:bo_stop_loss_value] > 1_000_000_000
132
- key(:bo_stop_loss_value).failure("must be less than 1,000,000,000")
133
- end
134
- end
135
- end
136
-
137
36
  rule(:drv_strike_price) do
138
- if values[:drv_strike_price].is_a?(Float)
139
- if values[:drv_strike_price].nan? || values[:drv_strike_price].infinite?
140
- key(:drv_strike_price).failure("must be a finite number")
141
- elsif values[:drv_strike_price] > 1_000_000_000
142
- key(:drv_strike_price).failure("must be less than 1,000,000,000")
143
- end
144
- end
145
- end
146
-
147
- # Custom validation for trigger price when the order type is STOP_LOSS or STOP_LOSS_MARKET.
148
- rule(:trigger_price, :order_type) do
149
- if values[:order_type] =~ /^STOP_LOSS/ && !values[:trigger_price]
150
- key(:trigger_price).failure("is required for order_type STOP_LOSS or STOP_LOSS_MARKET")
151
- end
152
- end
153
-
154
- # Custom validation for AMO time when the order is marked as after-market.
155
- rule(:after_market_order, :amo_time) do
156
- if values[:after_market_order] == true && !values[:amo_time]
157
- key(:amo_time).failure("is required when after_market_order is true")
158
- end
159
- end
160
-
161
- # Custom validation for Bracket Order (BO) fields.
162
- rule(:bo_profit_value, :bo_stop_loss_value, :product_type) do
163
- if values[:product_type] == "BO" && (!values[:bo_profit_value] || !values[:bo_stop_loss_value])
164
- key(:bo_profit_value).failure("is required for Bracket Orders")
165
- key(:bo_stop_loss_value).failure("is required for Bracket Orders")
37
+ if value.is_a?(Float) && (value.nan? || value.infinite?)
38
+ key.failure("must be a finite number")
166
39
  end
167
40
  end
168
41
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates requests for P&L based exit configurations.
6
+ class PnlBasedExitContract < BaseContract
7
+ params do
8
+ optional(:dhanClientId).maybe(:string)
9
+ optional(:profitValue).maybe(:float, gt?: 0)
10
+ optional(:lossValue).maybe(:float, gt?: 0)
11
+ optional(:enableKillSwitch).maybe(:bool)
12
+ optional(:productType).array(:string, included_in?: %w[INTRADAY DELIVERY])
13
+ end
14
+
15
+ rule(:profitValue, :lossValue) do
16
+ key.failure("at least one of profitValue or lossValue must be provided") if !values[:profitValue] && !values[:lossValue]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -7,7 +7,7 @@ module DhanHQ
7
7
  params do
8
8
  required(:dhanClientId).filled(:string)
9
9
  required(:fromProductType).filled(:string, included_in?: PRODUCT_TYPES)
10
- required(:exchangeSegment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
10
+ required(:exchangeSegment).filled(:string, included_in?: EXCHANGE_SEGMENTS - %w[IDX_I NSE_COMM])
11
11
  required(:positionType).filled(:string, included_in?: %w[LONG SHORT CLOSED])
12
12
  required(:securityId).filled(:string)
13
13
  required(:convertQty).filled(:integer, gt?: 0)
@@ -15,9 +15,21 @@ module DhanHQ
15
15
  end
16
16
 
17
17
  rule(:toProductType, :fromProductType) do
18
- next unless values[:toProductType] == values[:fromProductType]
18
+ key(:toProductType).failure("must be different from fromProductType") if values[:toProductType] == values[:fromProductType]
19
19
 
20
- key(:toProductType).failure("must be different from fromProductType")
20
+ if %w[BO CO].include?(values[:toProductType]) || %w[BO CO].include?(values[:fromProductType])
21
+ key(:base).failure("cannot convert to or from Bracket (BO) or Cover (CO) orders")
22
+ end
23
+ end
24
+
25
+ # Segment-Based Product Restrictions for conversion
26
+ rule(:toProductType, :exchangeSegment) do
27
+ case values[:toProductType]
28
+ when DhanHQ::Constants::ProductType::CNC, DhanHQ::Constants::ProductType::MTF
29
+ key(:toProductType).failure("is only allowed for Equity segments (NSE_EQ, BSE_EQ)") unless /_EQ$/.match?(values[:exchangeSegment])
30
+ when DhanHQ::Constants::ProductType::MARGIN
31
+ key(:toProductType).failure("is not allowed for Equity Cash segments; use CNC or INTRADAY") if /_EQ$/.match?(values[:exchangeSegment])
32
+ end
21
33
  end
22
34
  end
23
35
  end
@@ -93,9 +93,14 @@ module DhanHQ
93
93
  optional(:drvStrikePrice).maybe(:float, gt?: 0)
94
94
  end
95
95
 
96
+ # Institutional macros
97
+ rule(:quantity).validate(:lot_size_multiple)
98
+ rule(:price).validate(:tick_size_multiple)
99
+ rule(:triggerPrice).validate(:tick_size_multiple)
100
+
96
101
  # Custom validation for trigger price when the order type is STOP_LOSS or STOP_LOSS_MARKET.
97
102
  rule(:triggerPrice, :orderType) do
98
- if values[:orderType].start_with?("STOP_LOSS") && !values[:triggerPrice]
103
+ if values[:orderType].start_with?(DhanHQ::Constants::OrderType::STOP_LOSS) && !values[:triggerPrice]
99
104
  key(:triggerPrice).failure("is required for orderType STOP_LOSS or STOP_LOSS_MARKET")
100
105
  end
101
106
  end
@@ -106,6 +111,10 @@ module DhanHQ
106
111
  key(:amoTime).failure("is required when afterMarketOrder is true")
107
112
  end
108
113
  end
114
+
115
+ # Validation to ensure slice amounts don't sum to more than something?
116
+ # For SliceOrder, total quantity might be split by exchange limits if it exceeds. Since the API handles splitting,
117
+ # we just need to make sure quantity matches lot sizes.
109
118
  end
110
119
  end
111
120
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates requests for configuring static IP addresses.
6
+ class UserIpContract < BaseContract
7
+ params do
8
+ optional(:dhanClientId).maybe(:string)
9
+ required(:ip).filled(:string)
10
+ required(:ipFlag).filled(:string, included_in?: %w[PRIMARY SECONDARY])
11
+ end
12
+ end
13
+ end
14
+ end
@@ -80,11 +80,12 @@ module DhanHQ
80
80
  self::HTTP_PATH
81
81
  end
82
82
 
83
- # Every model must either override this or set a Dry::Validation contract if they need validation
83
+ # Default class-level validation contract returns nil (no validation).
84
+ # Override in subclasses to provide a contract for class-level validation.
84
85
  #
85
- # @return [Dry::Validation::Contract] The validation contract
86
+ # @return [nil]
86
87
  def validation_contract
87
- raise NotImplementedError, "#{name} must implement `validation_contract`"
88
+ nil
88
89
  end
89
90
 
90
91
  # Validate attributes before creating a new instance
@@ -155,6 +156,14 @@ module DhanHQ
155
156
 
156
157
  # Instance Methods
157
158
 
159
+ # Default validation contract — returns nil (no validation).
160
+ # Models that require instance-level validation must override this method.
161
+ #
162
+ # @return [nil]
163
+ def validation_contract
164
+ nil
165
+ end
166
+
158
167
  # Update an existing resource
159
168
  #
160
169
  # @param attributes [Hash] Attributes to update
@@ -264,7 +273,7 @@ module DhanHQ
264
273
 
265
274
  # Validate attributes using contract
266
275
  def valid?
267
- contract_class = respond_to?(:validation_contract) ? validation_contract : self.class.validation_contract
276
+ contract_class = validation_contract || self.class.validation_contract
268
277
  return true unless contract_class
269
278
 
270
279
  contract = contract_class.is_a?(Class) ? contract_class.new : contract_class
@@ -48,7 +48,7 @@ module DhanHQ
48
48
 
49
49
  error_code = body[:errorCode] || response.status.to_s
50
50
  error_message = body[:errorMessage] || body[:message] || "Unknown error"
51
- if error_code == "DH-1111"
51
+ if error_code == DhanHQ::Constants::TradingErrorCode::NO_HOLDINGS
52
52
  error_message = "No holdings found for this account. Add holdings or wait for them to settle before retrying."
53
53
  end
54
54
 
@@ -71,7 +71,7 @@ module DhanHQ
71
71
  end
72
72
 
73
73
  error_text =
74
- if error_code == "DH-1111"
74
+ if error_code == DhanHQ::Constants::TradingErrorCode::NO_HOLDINGS
75
75
  "#{error_message} (error code: #{error_code})"
76
76
  else
77
77
  "#{error_code}: #{error_message}"
@@ -16,7 +16,7 @@ module DhanHQ
16
16
 
17
17
  # Validate instance attributes using the defined validation contract
18
18
  def validate!
19
- contract_class = respond_to?(:validation_contract) ? validation_contract : self.class.validation_contract
19
+ contract_class = validation_contract || self.class.validation_contract
20
20
  return unless contract_class
21
21
 
22
22
  contract = contract_class.is_a?(Class) ? contract_class.new : contract_class
@@ -6,6 +6,8 @@ module DhanHQ
6
6
  module Models
7
7
  # Model for alert/conditional orders. CRUD via AlertOrders resource; validated by AlertOrderContract.
8
8
  class AlertOrder < BaseModel
9
+ include Concerns::ApiResponseHandler
10
+
9
11
  HTTP_PATH = "/alerts/orders"
10
12
 
11
13
  attributes :alert_id, :exchange_segment, :security_id, :condition,
@@ -27,9 +29,7 @@ module DhanHQ
27
29
 
28
30
  def all
29
31
  response = resource.all
30
- return [] unless response.is_a?(Array)
31
-
32
- response.map { |attrs| new(attrs, skip_validation: true) }
32
+ parse_collection_response(response)
33
33
  end
34
34
 
35
35
  def find(alert_id)
@@ -48,27 +48,45 @@ module DhanHQ
48
48
 
49
49
  find(response["alertId"])
50
50
  end
51
+
52
+ ##
53
+ # Modify an existing conditional trigger/alert order.
54
+ #
55
+ # @param alert_id [String] The alert ID to modify
56
+ # @param params [Hash] Updated parameters (condition, orders, etc.)
57
+ #
58
+ # @return [AlertOrder, nil] Updated AlertOrder instance, or nil on failure
59
+ #
60
+ # @example Modify an alert order's condition
61
+ # updated = DhanHQ::Models::AlertOrder.modify("12345",
62
+ # condition: { comparing_value: 300 },
63
+ # orders: [{ quantity: 20 }]
64
+ # )
65
+ #
66
+ def modify(alert_id, params)
67
+ normalized = snake_case(params)
68
+ response = resource.update(alert_id, camelize_keys(normalized))
69
+ return nil unless success_response?(response)
70
+
71
+ find(alert_id)
72
+ end
51
73
  end
52
74
 
53
75
  def id
54
76
  alert_id&.to_s
55
77
  end
56
78
 
57
- def save # rubocop:disable Naming/PredicateMethod
79
+ def save
58
80
  return false unless valid?
59
81
 
60
82
  payload = to_request_params
61
83
  response = if new_record?
62
- self.class.resource.create(camelize_keys(payload))
84
+ self.class.resource.create(payload)
63
85
  else
64
- self.class.resource.update(id, camelize_keys(payload))
86
+ self.class.resource.update(id, payload)
65
87
  end
66
- return false if new_record? && !(response.is_a?(Hash) && response["alertId"])
67
- return false if !new_record? && !success_response?(response)
68
88
 
69
- @attributes.merge!(normalize_keys(response))
70
- assign_attributes
71
- true
89
+ handle_api_response(response, success_key: new_record? ? "alertId" : nil)
72
90
  end
73
91
 
74
92
  def destroy # rubocop:disable Naming/PredicateMethod
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module Concerns
6
+ # Shared behavior for handling API responses and error logging in models.
7
+ module ApiResponseHandler
8
+ private
9
+
10
+ # Handles a standard API response, merging attributes on success or logging error on failure.
11
+ #
12
+ # @param response [Hash, Array] The raw API response
13
+ # @param success_key [String, nil] Key to check for specific success identifier (e.g., "orderId")
14
+ # @param context [String] Context for logging (e.g., "[DhanHQ::Models::Order] Placement")
15
+ # @param error_target [Symbol] Where to store errors (defaults to :@errors)
16
+ # @return [Boolean] True if successful
17
+ def handle_api_response(response, success_key: nil, context: nil) # rubocop:disable Naming/PredicateMethod
18
+ response = response.with_indifferent_access if response.respond_to?(:with_indifferent_access)
19
+ is_hash = response.is_a?(Hash)
20
+ is_success = success_response?(response) || (is_hash && success_key && response[success_key])
21
+
22
+ if is_success
23
+ @attributes.merge!(normalize_keys(response))
24
+ assign_attributes
25
+ DhanHQ.logger&.info("#{context} successfully: #{identifier_from(response, success_key) || id}") if context
26
+ true
27
+ else
28
+ error_msg = is_hash ? response[:errorMessage] || response[:message] || "Unknown error" : "Invalid response format"
29
+ DhanHQ.logger&.error("#{context} failed: #{error_msg}") if context
30
+ instance_variable_set(error_target_variable, response) if is_hash
31
+ false
32
+ end
33
+ end
34
+
35
+ def error_target_variable
36
+ :@errors
37
+ end
38
+
39
+ # Safely fetches an ID or response identifier
40
+ def identifier_from(response, key)
41
+ response.is_a?(Hash) ? response[key] : nil
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ ##
6
+ # Model for EDIS (Electronic Delivery Instruction Slip) operations.
7
+ #
8
+ # EDIS is used for selling holdings from your demat account. The API provides
9
+ # endpoints to generate T-PIN, create eDIS forms, and check authorization status.
10
+ #
11
+ # @example Generate T-PIN
12
+ # DhanHQ::Models::Edis.generate_tpin
13
+ #
14
+ # @example Generate eDIS form
15
+ # response = DhanHQ::Models::Edis.generate_form(
16
+ # isin: "INE155A01022",
17
+ # qty: 10,
18
+ # exchange: "NSE",
19
+ # segment: "E",
20
+ # bulk: false
21
+ # )
22
+ #
23
+ # @example Check EDIS status for a security
24
+ # status = DhanHQ::Models::Edis.inquire(isin: "INE155A01022")
25
+ #
26
+ class Edis < BaseModel
27
+ HTTP_PATH = "/edis"
28
+
29
+ class << self
30
+ ##
31
+ # Provides a shared instance of the Edis resource.
32
+ #
33
+ # @return [DhanHQ::Resources::Edis] The Edis resource client instance
34
+ def resource
35
+ @resource ||= DhanHQ::Resources::Edis.new
36
+ end
37
+
38
+ ##
39
+ # Generate T-PIN for eDIS authorization.
40
+ #
41
+ # Triggers T-PIN generation which is sent to the user's registered mobile/email.
42
+ #
43
+ # @return [Hash] API response
44
+ #
45
+ # @example Generate T-PIN before selling holdings
46
+ # DhanHQ::Models::Edis.generate_tpin
47
+ #
48
+ def generate_tpin
49
+ resource.tpin
50
+ end
51
+
52
+ ##
53
+ # Generate an eDIS form for authorizing sale of holdings.
54
+ #
55
+ # @param isin [String] ISIN of the security (e.g., "INE155A01022")
56
+ # @param qty [Integer] Quantity to authorize for sale
57
+ # @param exchange [String] Exchange name (e.g., "NSE", "BSE")
58
+ # @param segment [String] Segment identifier (e.g., "E")
59
+ # @param bulk [Boolean] Whether this is a bulk authorization (default: false)
60
+ #
61
+ # @return [Hash] API response containing the eDIS form data
62
+ #
63
+ # @example Authorize sale of 10 shares
64
+ # DhanHQ::Models::Edis.generate_form(
65
+ # isin: "INE155A01022",
66
+ # qty: 10,
67
+ # exchange: "NSE",
68
+ # segment: "E"
69
+ # )
70
+ #
71
+ def generate_form(isin:, qty:, exchange:, segment:, bulk: false)
72
+ resource.form({ isin: isin, qty: qty, exchange: exchange, segment: segment, bulk: bulk })
73
+ end
74
+
75
+ ##
76
+ # Generate a bulk eDIS form for multiple securities.
77
+ #
78
+ # @param params [Hash] Bulk form parameters
79
+ # @return [Hash] API response
80
+ #
81
+ def generate_bulk_form(params)
82
+ resource.bulk_form(params)
83
+ end
84
+
85
+ ##
86
+ # Check EDIS authorization status for a security.
87
+ #
88
+ # @param isin [String] ISIN of the security to check
89
+ #
90
+ # @return [Hash] API response containing authorization status
91
+ #
92
+ # @example Check if EDIS is authorized
93
+ # status = DhanHQ::Models::Edis.inquire(isin: "INE155A01022")
94
+ #
95
+ def inquire(isin:)
96
+ resource.inquire(isin)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -180,12 +180,6 @@ module DhanHQ
180
180
  end
181
181
  end
182
182
 
183
- ##
184
- # ExpiredOptionsData objects are read-only, so no validation contract needed
185
- def validation_contract
186
- nil
187
- end
188
-
189
183
  ##
190
184
  # Gets call option data from the response.
191
185
  #
@@ -238,9 +232,9 @@ module DhanHQ
238
232
  # See {#call_data} or {#put_data} for structure details.
239
233
  def data_for_type(option_type)
240
234
  case option_type.upcase
241
- when "CALL"
235
+ when DhanHQ::Constants::OptionType::CALL
242
236
  call_data
243
- when "PUT"
237
+ when DhanHQ::Constants::OptionType::PUT
244
238
  put_data
245
239
  end
246
240
  end
@@ -466,7 +460,7 @@ module DhanHQ
466
460
  #
467
461
  # @return [Boolean] true if instrument type is "OPTIDX", false otherwise
468
462
  def index_options?
469
- instrument == "OPTIDX"
463
+ instrument == DhanHQ::Constants::InstrumentType::OPTIDX
470
464
  end
471
465
 
472
466
  ##
@@ -474,7 +468,7 @@ module DhanHQ
474
468
  #
475
469
  # @return [Boolean] true if instrument type is "OPTSTK", false otherwise
476
470
  def stock_options?
477
- instrument == "OPTSTK"
471
+ instrument == DhanHQ::Constants::InstrumentType::OPTSTK
478
472
  end
479
473
 
480
474
  ##
@@ -498,7 +492,7 @@ module DhanHQ
498
492
  #
499
493
  # @return [Boolean] true if drv_option_type is "CALL", false otherwise
500
494
  def call_option?
501
- drv_option_type == "CALL"
495
+ drv_option_type == DhanHQ::Constants::OptionType::CALL
502
496
  end
503
497
 
504
498
  ##
@@ -506,7 +500,7 @@ module DhanHQ
506
500
  #
507
501
  # @return [Boolean] true if drv_option_type is "PUT", false otherwise
508
502
  def put_option?
509
- drv_option_type == "PUT"
503
+ drv_option_type == DhanHQ::Constants::OptionType::PUT
510
504
  end
511
505
 
512
506
  ##
@@ -64,6 +64,8 @@ module DhanHQ
64
64
  # orders.each { |order| puts order.order_status }
65
65
  #
66
66
  class ForeverOrder < BaseModel
67
+ include Concerns::ApiResponseHandler
68
+
67
69
  attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
68
70
  :transaction_type, :exchange_segment, :product_type, :order_flag,
69
71
  :order_type, :validity, :trading_symbol, :security_id, :quantity,
@@ -115,9 +117,7 @@ module DhanHQ
115
117
  # end
116
118
  def all
117
119
  response = resource.all
118
- return [] unless response.is_a?(Array)
119
-
120
- response.map { |o| new(o, skip_validation: true) }
120
+ parse_collection_response(response)
121
121
  end
122
122
 
123
123
  ##
@@ -290,8 +290,11 @@ module DhanHQ
290
290
  def modify(new_params)
291
291
  raise "Order ID is required to modify a forever order" unless order_id
292
292
 
293
+ DhanHQ.logger&.info("[DhanHQ::Models::ForeverOrder] Modifying order #{order_id}")
293
294
  response = self.class.resource.update(order_id, new_params)
294
- return self.class.find(order_id) if self.class.send(:success_response?, response)
295
+ ctx = "[DhanHQ::Models::ForeverOrder] Modification"
296
+ success = handle_api_response(response, success_key: "orderId", context: ctx)
297
+ return self.class.find(order_id) if success
295
298
 
296
299
  nil
297
300
  end
@@ -317,7 +320,7 @@ module DhanHQ
317
320
  raise "Order ID is required to cancel a forever order" unless order_id
318
321
 
319
322
  response = self.class.resource.cancel(order_id)
320
- response["orderStatus"] == "CANCELLED"
323
+ response["orderStatus"] == DhanHQ::Constants::OrderStatus::CANCELLED
321
324
  end
322
325
  end
323
326
  end