DhanHQ 2.6.2 → 2.6.3

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -3
  3. data/ARCHITECTURE.md +113 -0
  4. data/CHANGELOG.md +31 -0
  5. data/README.md +2 -0
  6. data/docs/API_VERIFICATION.md +10 -8
  7. data/docs/ENDPOINTS_AND_SANDBOX.md +12 -0
  8. data/lib/DhanHQ/auth.rb +2 -2
  9. data/lib/DhanHQ/client.rb +42 -34
  10. data/lib/DhanHQ/configuration.rb +5 -6
  11. data/lib/DhanHQ/constants.rb +67 -7
  12. data/lib/DhanHQ/contracts/alert_order_contract.rb +23 -16
  13. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +4 -2
  14. data/lib/DhanHQ/contracts/forever_order_contract.rb +55 -0
  15. data/lib/DhanHQ/contracts/historical_data_contract.rb +17 -19
  16. data/lib/DhanHQ/contracts/intraday_historical_data_contract.rb +12 -0
  17. data/lib/DhanHQ/contracts/margin_calculator_contract.rb +19 -17
  18. data/lib/DhanHQ/contracts/market_feed_contract.rb +42 -0
  19. data/lib/DhanHQ/contracts/multi_scrip_margin_calc_request_contract.rb +8 -5
  20. data/lib/DhanHQ/contracts/option_chain_contract.rb +17 -19
  21. data/lib/DhanHQ/contracts/pnl_based_exit_contract.rb +1 -1
  22. data/lib/DhanHQ/contracts/slice_order_contract.rb +10 -10
  23. data/lib/DhanHQ/core/auth_api.rb +1 -1
  24. data/lib/DhanHQ/core/base_api.rb +9 -9
  25. data/lib/DhanHQ/core/base_model.rb +4 -1
  26. data/lib/DhanHQ/core/error_handler.rb +2 -2
  27. data/lib/DhanHQ/errors.rb +14 -2
  28. data/lib/DhanHQ/helpers/request_helper.rb +11 -2
  29. data/lib/DhanHQ/helpers/response_helper.rb +48 -19
  30. data/lib/DhanHQ/helpers/validation_helper.rb +4 -2
  31. data/lib/DhanHQ/models/alert_order.rb +6 -2
  32. data/lib/DhanHQ/models/edis.rb +20 -13
  33. data/lib/DhanHQ/models/expired_options_data.rb +54 -44
  34. data/lib/DhanHQ/models/forever_order.rb +16 -7
  35. data/lib/DhanHQ/models/historical_data.rb +40 -6
  36. data/lib/DhanHQ/models/instrument_helpers.rb +2 -1
  37. data/lib/DhanHQ/models/margin.rb +62 -82
  38. data/lib/DhanHQ/models/market_feed.rb +14 -3
  39. data/lib/DhanHQ/models/option_chain.rb +50 -150
  40. data/lib/DhanHQ/models/order.rb +19 -4
  41. data/lib/DhanHQ/resources/alert_orders.rb +1 -1
  42. data/lib/DhanHQ/resources/edis.rb +4 -3
  43. data/lib/DhanHQ/resources/instruments.rb +3 -2
  44. data/lib/DhanHQ/resources/ip_setup.rb +4 -1
  45. data/lib/DhanHQ/resources/kill_switch.rb +7 -1
  46. data/lib/DhanHQ/resources/orders.rb +1 -1
  47. data/lib/DhanHQ/resources/super_orders.rb +8 -2
  48. data/lib/DhanHQ/resources/trader_control.rb +13 -4
  49. data/lib/DhanHQ/version.rb +1 -1
  50. data/lib/DhanHQ/ws/base_connection.rb +1 -1
  51. data/lib/DhanHQ/ws/market_depth/client.rb +11 -4
  52. data/lib/dhan_hq.rb +17 -20
  53. data/lib/ta/indicators.rb +15 -18
  54. metadata +6 -9
  55. data/CODE_REVIEW_ISSUES.md +0 -397
  56. data/FIXES_APPLIED.md +0 -373
  57. data/RELEASING.md +0 -60
  58. data/REVIEW_SUMMARY.md +0 -120
  59. data/VERSION_UPDATE.md +0 -82
  60. data/diagram.md +0 -34
  61. data/docs/ARCHIVE_README.md +0 -784
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/forever/orders (create Forever / GTT order).
6
+ # orderFlag: SINGLE | OCO. productType: CNC | MTF. For OCO, price1, triggerPrice1, quantity1 required.
7
+ class ForeverOrderCreateContract < BaseContract
8
+ params do
9
+ required(:dhan_client_id).filled(:string)
10
+ required(:order_flag).filled(:string, included_in?: OrderFlag::ALL)
11
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
12
+ required(:exchange_segment).filled(:string, included_in?: FOREVER_ORDER_SEGMENTS)
13
+ required(:product_type).filled(:string, included_in?: FOREVER_ORDER_PRODUCT_TYPES)
14
+ required(:order_type).filled(:string, included_in?: %w[LIMIT MARKET])
15
+ required(:validity).filled(:string, included_in?: VALIDITY_TYPES)
16
+ required(:security_id).filled(:string)
17
+ required(:quantity).filled(:integer, gt?: 0)
18
+ required(:price).filled(:float, gt?: 0)
19
+ required(:trigger_price).filled(:float)
20
+ optional(:correlation_id).maybe(:string, max_size?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
21
+ optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
22
+ optional(:price1).maybe(:float, gt?: 0)
23
+ optional(:trigger_price1).maybe(:float)
24
+ optional(:quantity1).maybe(:integer, gt?: 0)
25
+ end
26
+
27
+ rule(:order_flag) do
28
+ next unless value == DhanHQ::Constants::OrderFlag::OCO
29
+
30
+ missing = []
31
+ missing << "price1" if values[:price1].nil? || values[:price1].to_f <= 0
32
+ missing << "trigger_price1" if values[:trigger_price1].nil?
33
+ missing << "quantity1" if values[:quantity1].nil? || values[:quantity1].to_i < 1
34
+ key.failure("required for OCO: #{missing.join(", ")}") if missing.any?
35
+ end
36
+ end
37
+
38
+ # Validates request for PUT /v2/forever/orders/{order-id} (modify Forever order).
39
+ # orderType: LIMIT | MARKET | STOP_LOSS | STOP_LOSS_MARKET. legName: TARGET_LEG | STOP_LOSS_LEG.
40
+ class ForeverOrderModifyContract < BaseContract
41
+ params do
42
+ required(:dhan_client_id).filled(:string)
43
+ required(:order_id).filled(:string)
44
+ required(:order_flag).filled(:string, included_in?: OrderFlag::ALL)
45
+ required(:order_type).filled(:string, included_in?: ORDER_TYPES)
46
+ required(:leg_name).filled(:string, included_in?: %w[TARGET_LEG STOP_LOSS_LEG])
47
+ required(:quantity).filled(:integer, gt?: 0)
48
+ required(:price).filled(:float, gt?: 0)
49
+ required(:trigger_price).filled(:float)
50
+ required(:validity).filled(:string, included_in?: VALIDITY_TYPES)
51
+ optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -4,40 +4,38 @@ require "date"
4
4
 
5
5
  module DhanHQ
6
6
  module Contracts
7
- # Validates payloads for the historical data endpoints.
7
+ # Validates payloads for POST /v2/charts/historical (daily OHLC). No interval.
8
8
  class HistoricalDataContract < BaseContract
9
9
  params do
10
- # Common required fields
11
10
  required(:security_id).filled(:string)
12
- required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
11
+ required(:exchange_segment).filled(:string, included_in?: CHART_EXCHANGE_SEGMENTS)
13
12
  required(:instrument).filled(:string, included_in?: INSTRUMENTS)
13
+ required(:from_date).filled(:string, format?: /\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
14
+ required(:to_date).filled(:string, format?: /\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
14
15
 
15
- # Date range required for both Daily & Intraday
16
- required(:from_date).filled(:string, format?: /\A\d{4}-\d{2}-\d{2}\z/)
17
- required(:to_date).filled(:string, format?: /\A\d{4}-\d{2}-\d{2}\z/)
18
-
19
- # Optional fields
20
- optional(:expiry_code).maybe(:integer, included_in?: [0, 1, 2])
21
-
22
- # For intraday, the user can supply an "interval"
23
- # (valid: 1, 5, 15, 25, 60)
24
- optional(:interval).maybe(:string, included_in?: %w[1 5 15 25 60])
16
+ optional(:expiry_code).maybe(:integer, included_in?: ExpiryCode::ALL)
17
+ optional(:interval).maybe(:string, included_in?: CHART_INTERVALS)
18
+ optional(:oi).maybe(:bool)
25
19
  end
26
20
 
27
21
  rule(:from_date) do
28
- next unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
22
+ next unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
29
23
 
30
- d = Date.parse(value)
31
- key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(d)
24
+ # Only validate weekend for pure date strings (YYYY-MM-DD)
25
+ if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
26
+ d = Date.parse(value)
27
+ key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(d)
28
+ end
32
29
  rescue Date::Error
33
30
  key.failure("invalid date format")
34
31
  end
35
32
 
36
33
  rule(:from_date, :to_date) do
37
- next unless values[:from_date].match?(/\A\d{4}-\d{2}-\d{2}\z/) && values[:to_date].match?(/\A\d{4}-\d{2}-\d{2}\z/)
34
+ next unless values[:from_date].match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/) &&
35
+ values[:to_date].match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
38
36
 
39
- from_date = Date.parse(values[:from_date])
40
- to_date = Date.parse(values[:to_date])
37
+ from_date = DateTime.parse(values[:from_date])
38
+ to_date = DateTime.parse(values[:to_date])
41
39
  key.failure("from_date must be before to_date") if from_date >= to_date
42
40
  rescue Date::Error
43
41
  key.failure("invalid date format")
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates payloads for POST /v2/charts/intraday (minute OHLC). Requires interval.
6
+ class IntradayHistoricalDataContract < HistoricalDataContract
7
+ params do
8
+ required(:interval).filled(:string, included_in?: CHART_INTERVALS)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -2,39 +2,41 @@
2
2
 
3
3
  module DhanHQ
4
4
  module Contracts
5
- # Validates requests sent to the margin calculator endpoint.
6
- class MarginCalculatorContract < Dry::Validation::Contract
5
+ # Validates request for POST /v2/margincalculator (single order).
6
+ # dhanClientId, exchangeSegment (NSE_EQ|NSE_FNO|BSE_EQ|BSE_FNO|MCX_COMM), transactionType, quantity,
7
+ # productType (CNC|INTRADAY|MARGIN|MTF), securityId, price (required);
8
+ # orderType (optional, but required by some accounts); triggerPrice (optional, for SL-M/SL-L).
9
+ class MarginCalculatorContract < BaseContract
7
10
  params do
8
11
  required(:dhanClientId).filled(:string)
9
- required(:exchangeSegment).filled(:string, included_in?: DhanHQ::Constants::ExchangeSegment::ALL)
10
- required(:transactionType).filled(:string, included_in?: DhanHQ::Constants::TransactionType::ALL)
12
+ required(:exchangeSegment).filled(:string, included_in?: MARGIN_CALCULATOR_SEGMENTS)
13
+ required(:transactionType).filled(:string, included_in?: TRANSACTION_TYPES)
11
14
  required(:quantity).filled(:integer, gt?: 0)
12
- required(:productType).filled(:string, included_in?: DhanHQ::Constants::ProductType::ALL)
15
+ required(:productType).filled(:string, included_in?: MARGIN_PRODUCT_TYPES)
16
+ optional(:orderType).maybe(:string, included_in?: ORDER_TYPES)
13
17
  required(:securityId).filled(:string)
14
- optional(:price).maybe(:float, gt?: 0)
18
+ required(:price).filled(:float, gt?: 0)
15
19
  optional(:triggerPrice).maybe(:float)
16
20
  end
21
+
17
22
  rule(:price) do
18
- if values[:price]
19
- if values[:price] <= 0
20
- key(:price).failure("must be greater than 0")
21
- elsif values[:price].is_a?(Float) && (values[:price].nan? || values[:price].infinite?)
22
- key(:price).failure("must be a finite number")
23
- end
24
- end
23
+ next unless values[:price].is_a?(Float)
24
+
25
+ key.failure("must be a finite number") if values[:price].nan? || values[:price].infinite?
25
26
  end
26
27
 
27
28
  rule(:triggerPrice) do
28
- key(:triggerPrice).failure("must be a finite number") if values[:triggerPrice].is_a?(Float) && (values[:triggerPrice].nan? || values[:triggerPrice].infinite?)
29
+ next unless values[:triggerPrice].is_a?(Float)
30
+
31
+ key.failure("must be a finite number") if values[:triggerPrice].nan? || values[:triggerPrice].infinite?
29
32
  end
30
33
 
31
- # Segment-Based Product Restrictions for margin calculations
32
34
  rule(:productType, :exchangeSegment) do
33
35
  case values[:productType]
34
36
  when DhanHQ::Constants::ProductType::CNC, DhanHQ::Constants::ProductType::MTF
35
- key(:productType).failure("is only allowed for Equity segments (NSE_EQ, BSE_EQ)") unless /_EQ$/.match?(values[:exchangeSegment])
37
+ key.failure("is only allowed for Equity segments (NSE_EQ, BSE_EQ)") unless values[:exchangeSegment].to_s.end_with?("_EQ")
36
38
  when DhanHQ::Constants::ProductType::MARGIN
37
- key(:productType).failure("is not allowed for Equity Cash segments; use CNC or INTRADAY") if /_EQ$/.match?(values[:exchangeSegment])
39
+ key.failure("is not allowed for Equity Cash segments; use CNC or INTRADAY") if values[:exchangeSegment].to_s.end_with?("_EQ")
38
40
  end
39
41
  end
40
42
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request payloads for Market Feed endpoints (LTP, OHLC, Quote).
6
+ #
7
+ # The Market Feed API expects a payload where keys are Exchange Segments
8
+ # and values are Arrays of security IDs (Integers).
9
+ #
10
+ # @example Valid payload:
11
+ # {
12
+ # "NSE_EQ": [11536, 3456],
13
+ # "NSE_FNO": [49081, 49082]
14
+ # }
15
+ #
16
+ class MarketFeedContract < BaseContract
17
+ params do
18
+ config.validate_keys = true
19
+
20
+ # Dynamically define all valid exchange segments as optional keys.
21
+ # Each must be an array of integers.
22
+ EXCHANGE_SEGMENTS.each do |segment|
23
+ optional(segment.to_sym).array(:integer)
24
+ end
25
+ end
26
+
27
+ rule do
28
+ base.failure("must provide at least one exchange segment and security ID") if values.to_h.empty?
29
+
30
+ total_instruments = 0
31
+ values.to_h.each do |key, value|
32
+ if value.is_a?(Array)
33
+ key(key).failure("must not be empty") if value.empty?
34
+ total_instruments += value.size
35
+ end
36
+ end
37
+
38
+ base.failure("cannot fetch more than 1000 instruments in a single request (found #{total_instruments})") if total_instruments > 1000
39
+ end
40
+ end
41
+ end
42
+ end
@@ -2,20 +2,23 @@
2
2
 
3
3
  module DhanHQ
4
4
  module Contracts
5
- # Validates requests for multi-scrip margin calculations.
5
+ # Validates request for POST /v2/margincalculator/multi.
6
+ # Top-level: includePosition, includeOrder, dhanClientId, scripList.
7
+ # Each scrip: exchangeSegment, transactionType, quantity, productType, securityId, price; triggerPrice optional.
6
8
  class MultiScripMarginCalcRequestContract < BaseContract
7
9
  params do
8
10
  optional(:dhanClientId).maybe(:string)
9
11
  optional(:includePosition).maybe(:bool)
10
12
  optional(:includeOrder).maybe(:bool)
11
13
  required(:scripList).array(:hash) do
12
- required(:exchangeSegment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
14
+ required(:exchangeSegment).filled(:string, included_in?: MARGIN_CALCULATOR_SEGMENTS)
13
15
  required(:transactionType).filled(:string, included_in?: TRANSACTION_TYPES)
14
16
  required(:quantity).filled(:integer, gt?: 0)
15
- required(:productType).filled(:string, included_in?: PRODUCT_TYPES)
17
+ required(:productType).filled(:string, included_in?: MARGIN_PRODUCT_TYPES)
18
+ optional(:orderType).maybe(:string, included_in?: ORDER_TYPES)
16
19
  required(:securityId).filled(:string)
17
- optional(:price).maybe(:float, gt?: 0)
18
- optional(:triggerPrice).maybe(:float, gt?: 0)
20
+ required(:price).filled(:float, gt?: 0)
21
+ optional(:triggerPrice).maybe(:float)
19
22
  end
20
23
  end
21
24
  end
@@ -1,40 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base_contract"
3
+ require "date"
4
4
 
5
5
  module DhanHQ
6
6
  module Contracts
7
- # **Validation contract for fetching option chain data**
8
- #
9
- # Validates request parameters for fetching option chains.
7
+ # Validates request for POST /v2/optionchain (option chain by underlying and expiry).
8
+ # UnderlyingScrip (int), UnderlyingSeg (enum), Expiry (YYYY-MM-DD). Rate limit: 1 request per 3 seconds.
10
9
  class OptionChainContract < BaseContract
11
10
  params do
12
- required(:underlying_scrip).filled(:integer) # Security ID
13
- required(:underlying_seg).filled(:string, included_in?: %w[IDX_I NSE_FNO BSE_FNO MCX_FO])
11
+ required(:underlying_scrip).filled(:integer)
12
+ required(:underlying_seg).filled(:string, included_in?: OPTION_CHAIN_UNDERLYING_SEGMENTS)
14
13
  required(:expiry).filled(:string)
15
14
  end
16
15
 
17
16
  rule(:expiry) do
18
- # Ensure the expiry date is in "YYYY-MM-DD" format
19
- key.failure("must be in 'YYYY-MM-DD' format") unless value.match?(/^\d{4}-\d{2}-\d{2}$/)
17
+ next unless value.is_a?(String)
20
18
 
21
- # Ensure it is a valid date
22
- begin
23
- parsed_date = Date.parse(value)
24
- key.failure("must be a valid date") unless parsed_date.to_s == value
25
- rescue ArgumentError
26
- key.failure("is not a valid date")
19
+ unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
20
+ key.failure("must be in YYYY-MM-DD format")
21
+ next
27
22
  end
23
+
24
+ Date.parse(value)
25
+ rescue StandardError
26
+ key.failure("must be a valid date")
28
27
  end
29
28
  end
30
29
 
31
- # **Validation contract for fetching option chain expiry list**
32
- #
33
- # Validates request parameters for fetching expiry lists (expiry not required).
30
+ # Validates request for POST /v2/optionchain/expirylist (expiry list for an underlying).
31
+ # UnderlyingScrip (int), UnderlyingSeg (enum). No Expiry.
34
32
  class OptionChainExpiryListContract < BaseContract
35
33
  params do
36
- required(:underlying_scrip).filled(:integer) # Security ID
37
- required(:underlying_seg).filled(:string, included_in?: %w[IDX_I NSE_FNO BSE_FNO MCX_FO])
34
+ required(:underlying_scrip).filled(:integer)
35
+ required(:underlying_seg).filled(:string, included_in?: OPTION_CHAIN_UNDERLYING_SEGMENTS)
38
36
  end
39
37
  end
40
38
  end
@@ -13,7 +13,7 @@ module DhanHQ
13
13
  end
14
14
 
15
15
  rule(:profitValue, :lossValue) do
16
- key.failure("at least one of profitValue or lossValue must be provided") if !values[:profitValue] && !values[:lossValue]
16
+ key.failure("at least one of profitValue or lossValue must be provided") unless values[:profitValue] || values[:lossValue]
17
17
  end
18
18
  end
19
19
  end
@@ -31,7 +31,7 @@ module DhanHQ
31
31
  # Parameters and validation rules for the slicing order request.
32
32
  #
33
33
  # @!attribute [r] correlationId
34
- # @return [String] Optional. Identifier for tracking, max length 25 characters.
34
+ # @return [String] Optional. Identifier for tracking, max length 30 characters (per orders doc).
35
35
  # @!attribute [r] transactionType
36
36
  # @return [String] Required. BUY or SELL.
37
37
  # @!attribute [r] exchangeSegment
@@ -39,13 +39,13 @@ module DhanHQ
39
39
  # Must be one of: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM.
40
40
  # @!attribute [r] productType
41
41
  # @return [String] Required. Product type for the order.
42
- # Must be one of: CNC, INTRADAY, MARGIN, MTF, CO, BO.
42
+ # Must be one of: CNC, INTRADAY, MARGIN, MTF (per orders doc).
43
43
  # @!attribute [r] orderType
44
44
  # @return [String] Required. Type of order.
45
45
  # Must be one of: LIMIT, MARKET, STOP_LOSS, STOP_LOSS_MARKET.
46
46
  # @!attribute [r] validity
47
47
  # @return [String] Required. Validity of the order.
48
- # Must be one of: DAY, IOC, GTC, GTD.
48
+ # Must be one of: DAY, IOC (per orders doc).
49
49
  # @!attribute [r] securityId
50
50
  # @return [String] Required. Security identifier for the order.
51
51
  # @!attribute [r] quantity
@@ -59,11 +59,11 @@ module DhanHQ
59
59
  # @!attribute [r] afterMarketOrder
60
60
  # @return [Boolean] Optional. Indicates if this is an after-market order.
61
61
  # @!attribute [r] amoTime
62
- # @return [String] Optional. Time for after-market orders. Must be one of: OPEN, OPEN_30, OPEN_60.
62
+ # @return [String] Optional. Time for after-market orders. Must be one of: PRE_OPEN, OPEN, OPEN_30, OPEN_60.
63
63
  # @!attribute [r] boProfitValue
64
- # @return [Float] Optional. Profit value for Bracket Orders, must be > 0 if provided.
64
+ # @return [Float] Optional. Profit value for Bracket Orders (not used when productType is CNC/INTRADAY/MARGIN/MTF).
65
65
  # @!attribute [r] boStopLossValue
66
- # @return [Float] Optional. Stop-loss value for Bracket Orders, must be > 0 if provided.
66
+ # @return [Float] Optional. Stop-loss value for Bracket Orders (not used when productType is CNC/INTRADAY/MARGIN/MTF).
67
67
  # @!attribute [r] drvExpiryDate
68
68
  # @return [String] Optional. Expiry date for derivative contracts.
69
69
  # @!attribute [r] drvOptionType
@@ -71,21 +71,21 @@ module DhanHQ
71
71
  # @!attribute [r] drvStrikePrice
72
72
  # @return [Float] Optional. Strike price for options, must be > 0 if provided.
73
73
  params do
74
- optional(:correlationId).maybe(:string, max_size?: 25)
74
+ optional(:correlationId).maybe(:string, max_size?: 30)
75
75
  required(:transactionType).filled(:string, included_in?: %w[BUY SELL])
76
76
  required(:exchangeSegment).filled(:string,
77
77
  included_in?: %w[NSE_EQ NSE_FNO NSE_CURRENCY BSE_EQ BSE_FNO BSE_CURRENCY
78
78
  MCX_COMM])
79
- required(:productType).filled(:string, included_in?: %w[CNC INTRADAY MARGIN MTF CO BO])
79
+ required(:productType).filled(:string, included_in?: %w[CNC INTRADAY MARGIN MTF])
80
80
  required(:orderType).filled(:string, included_in?: %w[LIMIT MARKET STOP_LOSS STOP_LOSS_MARKET])
81
- required(:validity).filled(:string, included_in?: %w[DAY IOC GTC GTD])
81
+ required(:validity).filled(:string, included_in?: %w[DAY IOC])
82
82
  required(:securityId).filled(:string)
83
83
  required(:quantity).filled(:integer, gt?: 0)
84
84
  optional(:disclosedQuantity).maybe(:integer, gteq?: 0)
85
85
  optional(:price).maybe(:float, gt?: 0)
86
86
  optional(:triggerPrice).maybe(:float, gt?: 0)
87
87
  optional(:afterMarketOrder).maybe(:bool)
88
- optional(:amoTime).maybe(:string, included_in?: %w[OPEN OPEN_30 OPEN_60])
88
+ optional(:amoTime).maybe(:string, included_in?: %w[PRE_OPEN OPEN OPEN_30 OPEN_60])
89
89
  optional(:boProfitValue).maybe(:float, gt?: 0)
90
90
  optional(:boStopLossValue).maybe(:float, gt?: 0)
91
91
  optional(:drvExpiryDate).maybe(:string)
@@ -8,7 +8,7 @@ module DhanHQ
8
8
  # This class intentionally lives at the top-level namespace so it autoloads
9
9
  # cleanly from `lib/DhanHQ/core/auth_api.rb` with Zeitwerk `collapse`.
10
10
  class AuthAPI
11
- BASE_URL = "https://auth.dhan.co"
11
+ BASE_URL = Constants::Urls::AUTH_BASE
12
12
 
13
13
  def connection
14
14
  @connection ||= Faraday.new(url: BASE_URL) do |faraday|
@@ -86,21 +86,21 @@ module DhanHQ
86
86
  "#{self.class::HTTP_PATH}#{endpoint}"
87
87
  end
88
88
 
89
- # Format parameters based on API endpoint
89
+ # Format parameters based on API endpoint. Uses path-based strategy (marketfeed: pass-through,
90
+ # optionchain: titleize, default: camelize).
90
91
  def format_params(endpoint, params)
91
92
  full_path = build_path(endpoint)
92
- return params if marketfeed_api?(full_path) || params.empty?
93
+ return params if params.empty?
93
94
 
94
- optionchain_api?(full_path) ? titleize_keys(params) : camelize_keys(params)
95
+ param_formatter_for(full_path).call(params)
95
96
  end
96
97
 
97
- # Determines if the API endpoint is for Option Chain
98
- def optionchain_api?(endpoint)
99
- endpoint.include?("/optionchain")
100
- end
98
+ # Returns a callable that formats params for the given path (Strategy).
99
+ def param_formatter_for(full_path)
100
+ return ->(p) { p } if full_path.include?("/marketfeed")
101
+ return ->(p) { titleize_keys(p) } if full_path.include?("/optionchain")
101
102
 
102
- def marketfeed_api?(endpoint)
103
- endpoint.include?("/marketfeed")
103
+ ->(p) { camelize_keys(p) }
104
104
  end
105
105
  end
106
106
  end
@@ -145,7 +145,10 @@ module DhanHQ
145
145
  def parse_collection_response(response)
146
146
  # Some endpoints return arrays, others might return a `[:data]` structure
147
147
  unless response.is_a?(Array) || (response.is_a?(Hash) && response[:data].is_a?(Array))
148
- DhanHQ.logger&.warn("[DhanHQ::BaseModel] Unexpected response format for collection: #{response.class}. Expected Array or Hash with :data key.")
148
+ DhanHQ.logger&.warn(
149
+ "[DhanHQ::BaseModel] Unexpected response format for collection: #{response.class}. " \
150
+ "Expected Array or Hash with :data key."
151
+ )
149
152
  return []
150
153
  end
151
154
 
@@ -10,9 +10,9 @@ module DhanHQ
10
10
  def self.handle(error)
11
11
  case error
12
12
  when Dry::Validation::Result
13
- raise "Validation Error: #{error.errors.to_h}"
13
+ raise DhanHQ::ValidationError, "Invalid parameters: #{error.errors.to_h}"
14
14
  else
15
- raise "Error: #{error.message}"
15
+ raise DhanHQ::Error, error.message
16
16
  end
17
17
  end
18
18
  end
data/lib/DhanHQ/errors.rb CHANGED
@@ -1,8 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DhanHQ
4
- # Base error class for all DhanHQ API errors
5
- class Error < StandardError; end
4
+ # Base error class for all DhanHQ API errors.
5
+ # When raised from API response handling, {#response_body} holds the parsed error payload.
6
+ class Error < StandardError
7
+ attr_reader :response_body
8
+
9
+ def initialize(message = nil, response_body: nil)
10
+ super(message)
11
+ @response_body = response_body
12
+ end
13
+ end
6
14
 
7
15
  # Authentication and access errors
8
16
  # Raised when access token cannot be resolved (missing config or provider returned nil).
@@ -36,6 +44,10 @@ module DhanHQ
36
44
 
37
45
  # Order and market data errors
38
46
  class OrderError < Error; end
47
+
48
+ # Raised when the 25-modifications-per-order API cap would be exceeded.
49
+ # Count is tracked per Order instance in this process only (see Order#modify).
50
+ class ModificationLimitError < Error; end
39
51
  # Raised when the API signals an issue with the requested data payload.
40
52
  class DataError < Error; end
41
53
 
@@ -79,9 +79,10 @@ module DhanHQ
79
79
  end
80
80
 
81
81
  out = payload
82
- if path && data_api?(path) && %i[post put patch].include?(method)
82
+ if path && %i[post put patch].include?(method)
83
83
  client_id = DhanHQ.configuration&.client_id
84
- if client_id && !payload.key?(:dhanClientId) && !payload.key?("dhanClientId")
84
+ needs_client_id = data_api?(path) || payload_requires_dhan_client_id?(path)
85
+ if client_id && needs_client_id && !payload.key?(:dhanClientId) && !payload.key?("dhanClientId")
85
86
  out = payload.dup
86
87
  if out.keys.any?(String)
87
88
  out["dhanClientId"] = client_id
@@ -97,5 +98,13 @@ module DhanHQ
97
98
  else req.body = out.to_json
98
99
  end
99
100
  end
101
+
102
+ # True when the path is one where the request body must include dhanClientId (order-api style).
103
+ def payload_requires_dhan_client_id?(path)
104
+ return false if path.nil? || path.empty?
105
+
106
+ prefixes = DhanHQ::Constants::PAYLOAD_REQUIRES_DHAN_CLIENT_ID_PREFIXES
107
+ prefixes.any? { |p| path.start_with?(p) }
108
+ end
100
109
  end
101
110
  end
@@ -3,6 +3,14 @@
3
3
  module DhanHQ
4
4
  # Helper mixin for normalising API responses and raising mapped errors.
5
5
  module ResponseHelper
6
+ STATUS_ERROR_FALLBACK = {
7
+ 400 => DhanHQ::InputExceptionError,
8
+ 401 => DhanHQ::InvalidAuthenticationError,
9
+ 403 => DhanHQ::InvalidAccessError,
10
+ 404 => DhanHQ::NotFoundError,
11
+ 429 => DhanHQ::RateLimitError
12
+ }.freeze
13
+
6
14
  private
7
15
 
8
16
  # Determines if the API response indicates success.
@@ -53,31 +61,52 @@ module DhanHQ
53
61
  end
54
62
 
55
63
  error_class = DhanHQ::Constants::DHAN_ERROR_MAPPING[error_code]
56
-
57
64
  unless error_class
58
- # Log unmapped error codes for investigation
59
65
  DhanHQ.logger&.warn("[DhanHQ] Unmapped error code: #{error_code} (status: #{response.status})")
66
+ error_class = status_fallback_error_class(response.status)
67
+ end
60
68
 
61
- error_class =
62
- case response.status
63
- when 400 then DhanHQ::InputExceptionError
64
- when 401 then DhanHQ::InvalidAuthenticationError
65
- when 403 then DhanHQ::InvalidAccessError
66
- when 404 then DhanHQ::NotFoundError
67
- when 429 then DhanHQ::RateLimitError
68
- when 500..599 then DhanHQ::InternalServerError
69
- else DhanHQ::OtherError
70
- end
69
+ message = build_error_text(error_code, error_message, body)
70
+ raise error_class.new(message, response_body: body)
71
+ end
72
+
73
+ def status_fallback_error_class(status)
74
+ STATUS_ERROR_FALLBACK[status] ||
75
+ (status.between?(500, 599) ? DhanHQ::InternalServerError : DhanHQ::OtherError)
76
+ end
77
+
78
+ def build_error_text(error_code, error_message, body = {})
79
+ text = if error_code == DhanHQ::Constants::TradingErrorCode::NO_HOLDINGS
80
+ "#{error_message} (error code: #{error_code})"
81
+ else
82
+ "#{error_code}: #{error_message}"
83
+ end
84
+
85
+ extra = extra_error_detail(body)
86
+ text += " | #{extra}" if extra
87
+
88
+ if error_code == DhanHQ::Constants::TradingErrorCode::INPUT_EXCEPTION
89
+ text += " (API does not return which field failed; check required params and value types for this endpoint.)"
71
90
  end
72
91
 
73
- error_text =
74
- if error_code == DhanHQ::Constants::TradingErrorCode::NO_HOLDINGS
75
- "#{error_message} (error code: #{error_code})"
76
- else
77
- "#{error_code}: #{error_message}"
78
- end
92
+ text
93
+ end
79
94
 
80
- raise error_class, error_text
95
+ # Returns any additional error detail from the response body (errors array, details, etc.).
96
+ def extra_error_detail(body)
97
+ return nil unless body.is_a?(Hash)
98
+
99
+ parts = []
100
+ if body[:errors].is_a?(Array) && body[:errors].any?
101
+ parts << body[:errors].join("; ")
102
+ end
103
+ if body[:details].is_a?(String) && body[:details].to_s.strip != ""
104
+ parts << body[:details].to_s
105
+ end
106
+ if body[:validationErrors].is_a?(Array) && body[:validationErrors].any?
107
+ parts << body[:validationErrors].map { |e| e.is_a?(Hash) ? e[:message] || e[:field] : e }.join("; ")
108
+ end
109
+ parts.empty? ? nil : parts.join(" ")
81
110
  end
82
111
 
83
112
  # Parses JSON response safely. Converts response body to a hash or array with indifferent access.
@@ -11,7 +11,9 @@ module DhanHQ
11
11
  contract = contract_class.new
12
12
  result = contract.call(params)
13
13
 
14
- raise DhanHQ::Error, "Validation Error: #{result.errors.to_h}" unless result.success?
14
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
15
+
16
+ result.to_h
15
17
  end
16
18
 
17
19
  # Validate instance attributes using the defined validation contract
@@ -23,7 +25,7 @@ module DhanHQ
23
25
 
24
26
  result = contract.call(@attributes)
25
27
  @errors = result.errors.to_h unless result.success?
26
- raise DhanHQ::Error, "Validation Error: #{@errors}" unless valid?
28
+ raise DhanHQ::ValidationError, "Invalid parameters: #{@errors}" unless valid?
27
29
  end
28
30
 
29
31
  # Checks if the current instance is valid