DhanHQ 2.5.0 → 2.6.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/CHANGELOG.md +78 -6
  4. data/GUIDE.md +57 -39
  5. data/README.md +24 -23
  6. data/docs/API_DOCS_GAPS.md +128 -0
  7. data/docs/API_VERIFICATION.md +10 -11
  8. data/docs/ARCHIVE_README.md +16 -16
  9. data/docs/AUTHENTICATION.md +1 -1
  10. data/docs/CONSTANTS_REFERENCE.md +477 -0
  11. data/docs/DATA_API_PARAMETERS.md +7 -7
  12. data/docs/{rails_websocket_integration.md → RAILS_WEBSOCKET_INTEGRATION.md} +10 -10
  13. data/docs/{standalone_ruby_websocket_integration.md → STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md} +32 -32
  14. data/docs/{technical_analysis.md → TECHNICAL_ANALYSIS.md} +3 -3
  15. data/docs/TESTING_GUIDE.md +84 -82
  16. data/docs/{websocket_integration.md → WEBSOCKET_INTEGRATION.md} +19 -19
  17. data/docs/WEBSOCKET_PROTOCOL.md +2 -2
  18. data/lib/DhanHQ/constants.rb +456 -151
  19. data/lib/DhanHQ/contracts/alert_order_contract.rb +37 -10
  20. data/lib/DhanHQ/contracts/base_contract.rb +22 -0
  21. data/lib/DhanHQ/contracts/edis_contract.rb +25 -0
  22. data/lib/DhanHQ/contracts/margin_calculator_contract.rb +27 -4
  23. data/lib/DhanHQ/contracts/modify_order_contract.rb +65 -12
  24. data/lib/DhanHQ/contracts/multi_scrip_margin_calc_request_contract.rb +23 -0
  25. data/lib/DhanHQ/contracts/order_contract.rb +171 -39
  26. data/lib/DhanHQ/contracts/place_order_contract.rb +14 -141
  27. data/lib/DhanHQ/contracts/pnl_based_exit_contract.rb +20 -0
  28. data/lib/DhanHQ/contracts/position_conversion_contract.rb +15 -3
  29. data/lib/DhanHQ/contracts/slice_order_contract.rb +10 -1
  30. data/lib/DhanHQ/contracts/user_ip_contract.rb +14 -0
  31. data/lib/DhanHQ/core/base_model.rb +13 -4
  32. data/lib/DhanHQ/helpers/response_helper.rb +2 -2
  33. data/lib/DhanHQ/helpers/validation_helper.rb +1 -1
  34. data/lib/DhanHQ/models/alert_order.rb +7 -11
  35. data/lib/DhanHQ/models/concerns/api_response_handler.rb +46 -0
  36. data/lib/DhanHQ/models/edis.rb +0 -9
  37. data/lib/DhanHQ/models/expired_options_data.rb +6 -12
  38. data/lib/DhanHQ/models/forever_order.rb +8 -5
  39. data/lib/DhanHQ/models/historical_data.rb +0 -8
  40. data/lib/DhanHQ/models/instrument.rb +1 -7
  41. data/lib/DhanHQ/models/instrument_helpers.rb +4 -4
  42. data/lib/DhanHQ/models/kill_switch.rb +1 -11
  43. data/lib/DhanHQ/models/margin.rb +2 -2
  44. data/lib/DhanHQ/models/order.rb +107 -126
  45. data/lib/DhanHQ/models/order_update.rb +7 -13
  46. data/lib/DhanHQ/models/pnl_exit.rb +1 -9
  47. data/lib/DhanHQ/models/position.rb +1 -1
  48. data/lib/DhanHQ/models/postback.rb +4 -13
  49. data/lib/DhanHQ/models/profile.rb +0 -10
  50. data/lib/DhanHQ/models/super_order.rb +13 -3
  51. data/lib/DhanHQ/models/trade.rb +11 -23
  52. data/lib/DhanHQ/resources/ip_setup.rb +16 -5
  53. data/lib/DhanHQ/resources/kill_switch.rb +9 -7
  54. data/lib/DhanHQ/resources/orders.rb +41 -41
  55. data/lib/DhanHQ/version.rb +1 -1
  56. data/lib/DhanHQ/ws/cmd_bus.rb +1 -1
  57. data/lib/DhanHQ/ws/orders/client.rb +6 -6
  58. data/lib/DhanHQ/ws/singleton_lock.rb +2 -1
  59. data/lib/dhanhq/analysis/options_buying_advisor.rb +2 -2
  60. data/lib/rubocop/cop/dhanhq/use_constants.rb +171 -0
  61. metadata +20 -23
  62. data/TODO-1.md +0 -14
  63. data/TODO.md +0 -127
  64. data/app/services/live/order_update_guard_support.rb +0 -75
  65. data/app/services/live/order_update_hub.rb +0 -76
  66. data/app/services/live/order_update_persistence_support.rb +0 -68
  67. data/docs/PR_2.2.0.md +0 -48
  68. data/examples/comprehensive_websocket_examples.rb +0 -148
  69. data/examples/instrument_finder_test.rb +0 -195
  70. data/examples/live_order_updates.rb +0 -118
  71. data/examples/market_depth_example.rb +0 -144
  72. data/examples/market_feed_example.rb +0 -81
  73. data/examples/order_update_example.rb +0 -105
  74. data/examples/trading_fields_example.rb +0 -215
  75. /data/docs/{live_order_updates.md → LIVE_ORDER_UPDATES.md} +0 -0
  76. /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)
@@ -76,21 +76,17 @@ module DhanHQ
76
76
  alert_id&.to_s
77
77
  end
78
78
 
79
- def save # rubocop:disable Naming/PredicateMethod
79
+ def save
80
80
  return false unless valid?
81
81
 
82
82
  payload = to_request_params
83
83
  response = if new_record?
84
- self.class.resource.create(camelize_keys(payload))
84
+ self.class.resource.create(payload)
85
85
  else
86
- self.class.resource.update(id, camelize_keys(payload))
86
+ self.class.resource.update(id, payload)
87
87
  end
88
- return false if new_record? && !(response.is_a?(Hash) && response["alertId"])
89
- return false if !new_record? && !success_response?(response)
90
88
 
91
- @attributes.merge!(normalize_keys(response))
92
- assign_attributes
93
- true
89
+ handle_api_response(response, success_key: new_record? ? "alertId" : nil)
94
90
  end
95
91
 
96
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
@@ -96,15 +96,6 @@ module DhanHQ
96
96
  resource.inquire(isin)
97
97
  end
98
98
  end
99
-
100
- ##
101
- # No validation contract needed — EDIS operations are simple API calls.
102
- #
103
- # @return [nil]
104
- # @api private
105
- def validation_contract
106
- nil
107
- end
108
99
  end
109
100
  end
110
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
@@ -181,14 +181,6 @@ module DhanHQ
181
181
  resource.intraday(params)
182
182
  end
183
183
  end
184
-
185
- ##
186
- # HistoricalData objects are read-only, so no validation contract is applied.
187
- #
188
- # @return [nil] No validation contract needed for read-only data
189
- def validation_contract
190
- nil
191
- end
192
184
  end
193
185
  end
194
186
  end
@@ -69,7 +69,7 @@ module DhanHQ
69
69
 
70
70
  instruments.find do |instrument|
71
71
  # For equity instruments, prefer underlying_symbol over symbol_name
72
- instrument_symbol = if instrument.instrument == "EQUITY" && instrument.underlying_symbol
72
+ instrument_symbol = if instrument.instrument == DhanHQ::Constants::InstrumentType::EQUITY && instrument.underlying_symbol
73
73
  case_sensitive ? instrument.underlying_symbol : instrument.underlying_symbol.upcase
74
74
  else
75
75
  case_sensitive ? instrument.symbol_name : instrument.symbol_name.upcase
@@ -155,12 +155,6 @@ module DhanHQ
155
155
  }
156
156
  end
157
157
  end
158
-
159
- private
160
-
161
- def validation_contract
162
- nil
163
- end
164
158
  end
165
159
  end
166
160
  end
@@ -161,14 +161,14 @@ module DhanHQ
161
161
  return seg if %w[IDX_I NSE_FNO BSE_FNO MCX_FO].include?(seg)
162
162
 
163
163
  # Index detection by instrument kind or segment
164
- return "IDX_I" if ins == "INDEX" || seg == "IDX_I"
164
+ return DhanHQ::Constants::ExchangeSegment::IDX_I if ins == DhanHQ::Constants::InstrumentType::INDEX || seg == DhanHQ::Constants::ExchangeSegment::IDX_I
165
165
 
166
166
  # Map equities/stock-related segments to respective FNO
167
- return "NSE_FNO" if seg.start_with?("NSE")
168
- return "BSE_FNO" if seg.start_with?("BSE")
167
+ return DhanHQ::Constants::ExchangeSegment::NSE_FNO if seg.start_with?("NSE")
168
+ return DhanHQ::Constants::ExchangeSegment::BSE_FNO if seg.start_with?("BSE")
169
169
 
170
170
  # Fallback to IDX_I to avoid contract rejection
171
- "IDX_I"
171
+ DhanHQ::Constants::ExchangeSegment::IDX_I
172
172
  end
173
173
  end
174
174
  end
@@ -68,7 +68,7 @@ module DhanHQ
68
68
  # end
69
69
  #
70
70
  def update(status)
71
- resource.update(kill_switch_status: status)
71
+ resource.update(status)
72
72
  end
73
73
 
74
74
  ##
@@ -158,16 +158,6 @@ module DhanHQ
158
158
 
159
159
  ##
160
160
  # No explicit validation contract is required for kill switch updates.
161
- #
162
- # Kill switch operations are simple status updates that don't require complex validation.
163
- # The API handles validation server-side.
164
- #
165
- # @return [nil] Always returns nil as no validation contract is needed
166
- #
167
- # @api private
168
- def validation_contract
169
- nil
170
- end
171
161
  end
172
162
  end
173
163
  end
@@ -47,8 +47,8 @@ module DhanHQ
47
47
  # Base path used to invoke the calculator.
48
48
  HTTP_PATH = "/v2/margincalculator"
49
49
 
50
- attr_reader :total_margin, :span_margin, :exposure_margin, :available_balance,
51
- :variable_margin, :insufficient_balance, :brokerage, :leverage
50
+ attributes :total_margin, :span_margin, :exposure_margin, :available_balance,
51
+ :variable_margin, :insufficient_balance, :brokerage, :leverage
52
52
 
53
53
  class << self
54
54
  ##