DhanHQ 2.6.1 → 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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -3
  3. data/ARCHITECTURE.md +113 -0
  4. data/CHANGELOG.md +55 -0
  5. data/README.md +2 -0
  6. data/Rakefile +3 -1
  7. data/docs/API_VERIFICATION.md +10 -8
  8. data/docs/ENDPOINTS_AND_SANDBOX.md +115 -0
  9. data/lib/DhanHQ/auth.rb +2 -2
  10. data/lib/DhanHQ/client.rb +72 -51
  11. data/lib/DhanHQ/configuration.rb +45 -11
  12. data/lib/DhanHQ/constants.rb +68 -4
  13. data/lib/DhanHQ/contracts/alert_order_contract.rb +23 -16
  14. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +4 -2
  15. data/lib/DhanHQ/contracts/forever_order_contract.rb +55 -0
  16. data/lib/DhanHQ/contracts/historical_data_contract.rb +17 -19
  17. data/lib/DhanHQ/contracts/intraday_historical_data_contract.rb +12 -0
  18. data/lib/DhanHQ/contracts/margin_calculator_contract.rb +19 -17
  19. data/lib/DhanHQ/contracts/market_feed_contract.rb +42 -0
  20. data/lib/DhanHQ/contracts/multi_scrip_margin_calc_request_contract.rb +8 -5
  21. data/lib/DhanHQ/contracts/option_chain_contract.rb +17 -19
  22. data/lib/DhanHQ/contracts/pnl_based_exit_contract.rb +1 -1
  23. data/lib/DhanHQ/contracts/slice_order_contract.rb +10 -10
  24. data/lib/DhanHQ/core/auth_api.rb +1 -1
  25. data/lib/DhanHQ/core/base_api.rb +10 -9
  26. data/lib/DhanHQ/core/base_model.rb +4 -1
  27. data/lib/DhanHQ/core/error_handler.rb +2 -2
  28. data/lib/DhanHQ/errors.rb +14 -2
  29. data/lib/DhanHQ/helpers/request_helper.rb +27 -5
  30. data/lib/DhanHQ/helpers/response_helper.rb +48 -19
  31. data/lib/DhanHQ/helpers/validation_helper.rb +4 -2
  32. data/lib/DhanHQ/models/alert_order.rb +6 -2
  33. data/lib/DhanHQ/models/edis.rb +20 -13
  34. data/lib/DhanHQ/models/expired_options_data.rb +54 -44
  35. data/lib/DhanHQ/models/forever_order.rb +17 -7
  36. data/lib/DhanHQ/models/historical_data.rb +40 -6
  37. data/lib/DhanHQ/models/instrument_helpers.rb +2 -1
  38. data/lib/DhanHQ/models/margin.rb +62 -82
  39. data/lib/DhanHQ/models/market_feed.rb +14 -3
  40. data/lib/DhanHQ/models/option_chain.rb +50 -150
  41. data/lib/DhanHQ/models/order.rb +19 -4
  42. data/lib/DhanHQ/models/super_order.rb +2 -2
  43. data/lib/DhanHQ/resources/alert_orders.rb +1 -1
  44. data/lib/DhanHQ/resources/edis.rb +4 -3
  45. data/lib/DhanHQ/resources/instruments.rb +3 -2
  46. data/lib/DhanHQ/resources/ip_setup.rb +4 -1
  47. data/lib/DhanHQ/resources/kill_switch.rb +7 -1
  48. data/lib/DhanHQ/resources/orders.rb +1 -1
  49. data/lib/DhanHQ/resources/super_orders.rb +8 -2
  50. data/lib/DhanHQ/resources/trader_control.rb +13 -4
  51. data/lib/DhanHQ/version.rb +1 -1
  52. data/lib/DhanHQ/ws/base_connection.rb +1 -1
  53. data/lib/DhanHQ/ws/client.rb +2 -1
  54. data/lib/DhanHQ/ws/market_depth/client.rb +16 -8
  55. data/lib/dhan_hq.rb +37 -32
  56. data/lib/ta/indicators.rb +15 -18
  57. metadata +7 -9
  58. data/CODE_REVIEW_ISSUES.md +0 -397
  59. data/FIXES_APPLIED.md +0 -373
  60. data/RELEASING.md +0 -60
  61. data/REVIEW_SUMMARY.md +0 -120
  62. data/VERSION_UPDATE.md +0 -82
  63. data/diagram.md +0 -34
  64. data/docs/ARCHIVE_README.md +0 -784
@@ -9,9 +9,12 @@ module DhanHQ
9
9
  # @see https://dhanhq.co/docs/v2/ DhanHQ API Documentation
10
10
  class Configuration
11
11
  # Default REST API host used when the base URL is not overridden.
12
- #
13
12
  # @return [String]
14
- BASE_URL = "https://api.dhan.co/v2"
13
+ BASE_URL = Constants::Urls::REST_API_BASE
14
+
15
+ # Default Sandbox API host.
16
+ # @return [String]
17
+ SANDBOX_URL = Constants::Urls::SANDBOX_API_BASE
15
18
  # The client ID for API authentication.
16
19
  # @return [String, nil] The client ID or `nil` if not set.
17
20
  attr_accessor :client_id
@@ -32,8 +35,11 @@ module DhanHQ
32
35
  attr_accessor :on_token_expired
33
36
 
34
37
  # The base URL for API requests.
35
- # @return [String] The base URL for the DhanHQ API.
36
- attr_accessor :base_url
38
+ # @return [String]
39
+ attr_writer :base_url
40
+
41
+ # Whether to use the sandbox environment.
42
+ attr_accessor :sandbox
37
43
 
38
44
  # URL for the compact CSV format of instruments.
39
45
  # @return [String] URL for compact CSV.
@@ -48,21 +54,33 @@ module DhanHQ
48
54
  attr_accessor :ws_version
49
55
 
50
56
  # Websocket order updates endpoint.
57
+ # Sandbox does not support WebSocket; always returns production URL unless overridden.
51
58
  # @return [String]
52
- attr_accessor :ws_order_url
59
+ def ws_order_url
60
+ @ws_order_url || Constants::Urls::WS_ORDER_UPDATE
61
+ end
53
62
 
54
63
  # Websocket market feed endpoint.
64
+ # Sandbox does not support WebSocket; always returns production URL unless overridden.
55
65
  # @return [String]
56
- attr_accessor :ws_market_feed_url
66
+ def ws_market_feed_url
67
+ @ws_market_feed_url || Constants::Urls::WS_MARKET_FEED
68
+ end
57
69
 
58
70
  # Websocket market depth endpoint.
71
+ # Sandbox does not support WebSocket; always returns production URL unless overridden.
59
72
  # @return [String]
60
- attr_accessor :ws_market_depth_url
73
+ def ws_market_depth_url
74
+ @ws_market_depth_url || Constants::Urls::WS_DEPTH_20
75
+ end
61
76
 
62
77
  # Market depth level (20 or 200).
63
78
  # @return [Integer]
64
79
  attr_accessor :market_depth_level
65
80
 
81
+ # Setters for websocket URLs
82
+ attr_writer :ws_order_url, :ws_market_feed_url, :ws_market_depth_url
83
+
66
84
  # Websocket user type for order updates.
67
85
  # @return [String] "SELF" or "PARTNER".
68
86
  attr_accessor :ws_user_type
@@ -91,6 +109,21 @@ module DhanHQ
91
109
  end
92
110
  end
93
111
 
112
+ # Returns the base URL to use. If {#sandbox} is true and {#base_url}
113
+ # is nil or the default production URL, returns {SANDBOX_URL}.
114
+ # @return [String]
115
+ def base_url
116
+ if sandbox? && (@base_url.nil? || @base_url == BASE_URL)
117
+ SANDBOX_URL
118
+ else
119
+ @base_url || BASE_URL
120
+ end
121
+ end
122
+
123
+ def sandbox?
124
+ @sandbox == true
125
+ end
126
+
94
127
  # Initializes a new configuration instance with default values.
95
128
  #
96
129
  # @example
@@ -100,11 +133,12 @@ module DhanHQ
100
133
  def initialize
101
134
  @client_id = ENV.fetch("DHAN_CLIENT_ID", nil)
102
135
  @access_token = ENV.fetch("DHAN_ACCESS_TOKEN", nil)
103
- @base_url = ENV.fetch("DHAN_BASE_URL", "https://api.dhan.co/v2")
136
+ @sandbox = ENV.fetch("DHAN_SANDBOX", "false").to_s.casecmp("true").zero?
137
+ @base_url = ENV.fetch("DHAN_BASE_URL", nil)
104
138
  @ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
105
- @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", "wss://api-order-update.dhan.co")
106
- @ws_market_feed_url = ENV.fetch("DHAN_WS_MARKET_FEED_URL", "wss://api-feed.dhan.co")
107
- @ws_market_depth_url = ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", "wss://depth-api-feed.dhan.co/twentydepth")
139
+ @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
140
+ @ws_market_feed_url = ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
141
+ @ws_market_depth_url = ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
108
142
  @market_depth_level = ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20").to_i
109
143
  @ws_user_type = ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
110
144
  @partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
@@ -18,6 +18,14 @@ module DhanHQ
18
18
  BSE_FNO = "BSE_FNO"
19
19
 
20
20
  ALL = [IDX_I, NSE_EQ, NSE_FNO, NSE_CURRENCY, NSE_COMM, BSE_EQ, MCX_COMM, BSE_CURRENCY, BSE_FNO].freeze
21
+ # Segments allowed by POST /v2/margincalculator (single and multi).
22
+ MARGIN_CALC_ALL = [NSE_EQ, NSE_FNO, BSE_EQ, BSE_FNO, MCX_COMM].freeze
23
+ # Segments allowed by POST /v2/forever/orders (create).
24
+ FOREVER_ORDER_ALL = [NSE_EQ, NSE_FNO, BSE_EQ, BSE_FNO, MCX_COMM].freeze
25
+ # Segments for conditional trigger (equities and indices only). POST/PUT /v2/alerts/orders.
26
+ ALERT_CONDITION_ALL = [NSE_EQ, BSE_EQ, IDX_I].freeze
27
+ # Segments allowed by POST /v2/charts/historical and POST /v2/charts/intraday (excludes NSE_COMM).
28
+ CHART_ALL = [IDX_I, NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM].freeze
21
29
  end
22
30
 
23
31
  # Product types for order placement.
@@ -30,6 +38,10 @@ module DhanHQ
30
38
  BO = "BO"
31
39
 
32
40
  ALL = [CNC, INTRADAY, MARGIN, MTF, CO, BO].freeze
41
+ # Product types allowed by POST /v2/margincalculator (single and multi).
42
+ MARGIN_CALC_ALL = [CNC, INTRADAY, MARGIN, MTF].freeze
43
+ # Product types allowed by POST /v2/forever/orders (create). Only CNC and MTF.
44
+ FOREVER_ORDER_ALL = [CNC, MTF].freeze
33
45
  end
34
46
 
35
47
  # Buy/Sell transaction types.
@@ -112,6 +124,17 @@ module DhanHQ
112
124
  # Backward-compatible alias kept for existing SDK usage.
113
125
  Instrument = InstrumentType
114
126
 
127
+ # Minute intervals allowed by POST /v2/charts/intraday (charts annexure).
128
+ module ChartInterval
129
+ ONE = "1"
130
+ FIVE = "5"
131
+ FIFTEEN = "15"
132
+ TWENTY_FIVE = "25"
133
+ SIXTY = "60"
134
+
135
+ ALL = [ONE, FIVE, FIFTEEN, TWENTY_FIVE, SIXTY].freeze
136
+ end
137
+
115
138
  # Option types for derivatives trading.
116
139
  module OptionType
117
140
  CALL = "CALL"
@@ -365,10 +388,33 @@ module DhanHQ
365
388
  ORDER_MODIFICATIONS_PER_ORDER = 25
366
389
  end
367
390
 
391
+ # Canonical Dhan API, auth, WebSocket and instrument URLs (see https://dhanhq.co/docs/v2/).
392
+ # Configuration and Auth use these as defaults; ENV overrides apply at runtime.
393
+ module Urls
394
+ REST_API_BASE = "https://api.dhan.co/v2"
395
+ SANDBOX_API_BASE = "https://sandbox.dhan.co/v2"
396
+ AUTH_BASE = "https://auth.dhan.co"
397
+ WS_MARKET_FEED = "wss://api-feed.dhan.co"
398
+ WS_ORDER_UPDATE = "wss://api-order-update.dhan.co"
399
+ WS_DEPTH_20 = "wss://depth-api-feed.dhan.co/twentydepth"
400
+ WS_DEPTH_200 = "wss://full-depth-api.dhan.co/twohundreddepth"
401
+ INSTRUMENT_CSV_COMPACT = "https://images.dhan.co/api-data/api-scrip-master.csv"
402
+ INSTRUMENT_CSV_DETAILED = "https://images.dhan.co/api-data/api-scrip-master-detailed.csv"
403
+ DOCS = "https://dhanhq.co/docs/v2"
404
+ # Origin header value for WebSocket connections (Dhan main site).
405
+ ORIGIN = "https://dhanhq.co"
406
+ end
407
+
368
408
  # Backward-compatible arrays used across existing validations.
369
409
  TRANSACTION_TYPES = TransactionType::ALL
370
410
  EXCHANGE_SEGMENTS = ExchangeSegment::ALL
411
+ CHART_EXCHANGE_SEGMENTS = ExchangeSegment::CHART_ALL
371
412
  INSTRUMENTS = InstrumentType::ALL
413
+ CHART_INTERVALS = ChartInterval::ALL
414
+ MARGIN_CALCULATOR_SEGMENTS = ExchangeSegment::MARGIN_CALC_ALL
415
+ MARGIN_PRODUCT_TYPES = ProductType::MARGIN_CALC_ALL
416
+ FOREVER_ORDER_SEGMENTS = ExchangeSegment::FOREVER_ORDER_ALL
417
+ FOREVER_ORDER_PRODUCT_TYPES = ProductType::FOREVER_ORDER_ALL
372
418
  PRODUCT_TYPES = ProductType::ALL
373
419
  ORDER_TYPES = OrderType::ALL
374
420
  VALIDITY_TYPES = Validity::ALL
@@ -376,6 +422,8 @@ module DhanHQ
376
422
  ORDER_STATUSES = OrderStatus::ALL
377
423
  COMPARISON_TYPES = ComparisonType::ALL
378
424
  OPERATORS = Operator::ALL
425
+ ALERT_CONDITION_SEGMENTS = ExchangeSegment::ALERT_CONDITION_ALL
426
+ ALERT_TIMEFRAMES = %w[DATE ONE_MIN FIVE_MIN FIFTEEN_MIN DAY].freeze
379
427
 
380
428
  # Exchange aliases used when building subscription payloads.
381
429
  NSE = ExchangeSegment::NSE_EQ
@@ -387,7 +435,8 @@ module DhanHQ
387
435
  BSE_FNO = ExchangeSegment::BSE_FNO
388
436
  INDEX = ExchangeSegment::IDX_I
389
437
 
390
- OPTION_SEGMENTS = [NSE, BSE, CUR, MCX, FNO, NSE_FNO, BSE_FNO, INDEX].freeze
438
+ # Underlying segments accepted by POST /v2/optionchain and POST /v2/optionchain/expirylist.
439
+ OPTION_CHAIN_UNDERLYING_SEGMENTS = %w[IDX_I NSE_FNO BSE_FNO MCX_FO].freeze
391
440
 
392
441
  # Canonical labels kept for compatibility with previous SDK versions.
393
442
  BUY = TransactionType::BUY
@@ -409,15 +458,30 @@ module DhanHQ
409
458
  IOC = Validity::IOC
410
459
 
411
460
  # Download URL for the compact instrument master CSV.
412
- COMPACT_CSV_URL = "https://images.dhan.co/api-data/api-scrip-master.csv"
461
+ COMPACT_CSV_URL = Urls::INSTRUMENT_CSV_COMPACT
413
462
  # Download URL for the detailed instrument master CSV.
414
- DETAILED_CSV_URL = "https://images.dhan.co/api-data/api-scrip-master-detailed.csv"
463
+ DETAILED_CSV_URL = Urls::INSTRUMENT_CSV_DETAILED
415
464
 
416
465
  # API route prefixes that require a `client-id` header in addition to the access token.
417
466
  DATA_API_PREFIXES = [
418
467
  "/v2/marketfeed/",
419
468
  "/v2/optionchain",
420
- "/v2/instrument/"
469
+ "/v2/instrument/",
470
+ "/v2/charts"
471
+ ].freeze
472
+
473
+ # Path prefixes for which the request body (POST/PUT/PATCH) must include dhanClientId.
474
+ # Injection is done in the client layer when building the payload.
475
+ PAYLOAD_REQUIRES_DHAN_CLIENT_ID_PREFIXES = %w[
476
+ /alerts/orders
477
+ /v2/orders
478
+ /v2/forever
479
+ /v2/super/orders
480
+ /v2/positions
481
+ /v2/pnlExit
482
+ /v2/margincalculator
483
+ /v2/killswitch
484
+ /v2/ip
421
485
  ].freeze
422
486
 
423
487
  # Mapping of exchange and segment combinations to canonical exchange segment names.
@@ -2,45 +2,52 @@
2
2
 
3
3
  module DhanHQ
4
4
  module Contracts
5
- # Validates alert order payloads for create/update per dhanhq.co/docs/v2/conditional-trigger/
6
- # Condition requires exchange_segment, exp_date, frequency; time_frame required for TECHNICAL_* comparison types.
5
+ # Validates Conditional Trigger (alert order) payloads for POST /v2/alerts/orders and PUT /v2/alerts/orders/{alertId}.
6
+ # Condition: exchangeSegment (NSE_EQ|BSE_EQ|IDX_I), timeframe (required), comparisonType, operator, expDate, frequency;
7
+ # indicatorName/time_frame required for TECHNICAL_* comparison types.
8
+ # Orders: transactionType, exchangeSegment, productType (CNC|INTRADAY|MARGIN|MTF), orderType, securityId,
9
+ # quantity, validity, price (required); discQuantity, triggerPrice optional.
7
10
  class AlertOrderContract < BaseContract
8
11
  params do
9
12
  required(:condition).hash do
10
13
  required(:security_id).filled(:string, max_size?: 20)
11
- required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
14
+ required(:exchange_segment).filled(:string, included_in?: ALERT_CONDITION_SEGMENTS)
12
15
  required(:comparison_type).filled(:string, included_in?: COMPARISON_TYPES)
13
- optional(:indicator_name).maybe(:string)
14
- optional(:time_frame).maybe(:string)
16
+ required(:time_frame).filled(:string, included_in?: ALERT_TIMEFRAMES)
15
17
  required(:operator).filled(:string, included_in?: OPERATORS)
18
+ required(:exp_date).filled(:string, format?: /\A\d{4}-\d{2}-\d{2}\z/)
19
+ required(:frequency).filled(:string)
20
+ optional(:indicator_name).maybe(:string)
16
21
  optional(:comparing_value).maybe(:float)
17
22
  optional(:comparing_indicator_name).maybe(:string)
18
- required(:exp_date).filled(:string)
19
- required(:frequency).filled(:string)
23
+ optional(:user_note).maybe(:string)
20
24
  end
21
25
  required(:orders).array(:hash) do
22
26
  required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
23
27
  required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
24
- required(:product_type).filled(:string, included_in?: PRODUCT_TYPES)
28
+ required(:product_type).filled(:string, included_in?: MARGIN_PRODUCT_TYPES)
25
29
  required(:order_type).filled(:string, included_in?: ORDER_TYPES)
26
30
  required(:security_id).filled(:string, max_size?: 20)
27
31
  required(:quantity).filled(:integer, gt?: 0)
28
32
  required(:validity).filled(:string, included_in?: VALIDITY_TYPES)
29
- optional(:price).maybe(:float)
30
- optional(:trigger_price).maybe(:float)
33
+ required(:price).filled # string or number; API expects string, coerce in serialization
34
+ optional(:disc_quantity).maybe(:string)
35
+ optional(:trigger_price).maybe(:string)
31
36
  end
32
37
  end
33
38
 
34
39
  rule(condition: :indicator_name) do
35
- if values[:condition] && values[:condition][:comparison_type].to_s.start_with?("TECHNICAL") && !value
36
- key(condition: :indicator_name).failure("is required for technical comparisons")
37
- end
40
+ next unless values.dig(:condition, :comparison_type).to_s.start_with?("TECHNICAL")
41
+ next if value && !value.to_s.strip.empty?
42
+
43
+ key(%i[condition indicator_name]).failure("is required for technical comparisons")
38
44
  end
39
45
 
40
46
  rule(condition: :time_frame) do
41
- if values[:condition] && values[:condition][:comparison_type].to_s.start_with?("TECHNICAL") && !value
42
- key(condition: :time_frame).failure("is required for technical comparisons")
43
- end
47
+ next unless values.dig(:condition, :comparison_type).to_s.start_with?("TECHNICAL")
48
+ next if value && !value.to_s.strip.empty?
49
+
50
+ key(%i[condition time_frame]).failure("is required for technical comparisons")
44
51
  end
45
52
  end
46
53
  end
@@ -23,7 +23,9 @@ module DhanHQ
23
23
  end
24
24
 
25
25
  rule(:exchange_segment) do
26
- valid_segments = %w[NSE_FNO BSE_FNO NSE_EQ BSE_EQ]
26
+ # IDX_I for index options, NSE_EQ/BSE_EQ for equity options,
27
+ # plus NSE_FNO/BSE_FNO for derivatives.
28
+ valid_segments = %w[IDX_I NSE_EQ BSE_EQ NSE_FNO BSE_FNO]
27
29
  key.failure("must be one of: #{valid_segments.join(", ")}") unless valid_segments.include?(value)
28
30
  end
29
31
 
@@ -43,7 +45,7 @@ module DhanHQ
43
45
  end
44
46
 
45
47
  rule(:strike) do
46
- unless value.match?(/\AATM(\+|-)?\d*\z/) || value == "ATM"
48
+ unless value.match?(/\AATM(\+|-)?\d+\z/) || value == "ATM"
47
49
  key.failure("must be in format ATM, ATM+1, ATM-1, etc. " \
48
50
  "(up to ATM+10/ATM-10 for Index Options, ATM+3/ATM-3 for others)")
49
51
  end
@@ -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