DhanHQ 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,24 @@ module DhanHQ
15
15
  # Default Sandbox API host.
16
16
  # @return [String]
17
17
  SANDBOX_URL = Constants::Urls::SANDBOX_API_BASE
18
+
19
+ # All environments a client can run in.
20
+ #
21
+ # - +:live+ — every request hits the production host.
22
+ # - +:sandbox+ — every request hits the sandbox host (previous `sandbox = true` behaviour).
23
+ # - +:hybrid+ — market data (quotes, option chain, charts, instruments, ScanX)
24
+ # hits production while trading/account requests hit the sandbox host, so a
25
+ # strategy rehearses execution against real prices without risking money.
26
+ # @return [Array<Symbol>]
27
+ MODES = %i[live sandbox hybrid].freeze
28
+
29
+ # API tiers routed to the production host when {#mode} is +:hybrid+.
30
+ # Everything else (orders, positions, funds, statements, ...) routes to the
31
+ # sandbox host. Global Stocks keeps its own tier so it stays on production in
32
+ # hybrid mode — the sandbox host does not mirror the US-equities API.
33
+ # @return [Array<Symbol>]
34
+ HYBRID_LIVE_API_TYPES = %i[data_api quote_api option_chain global_stocks_api].freeze
35
+
18
36
  # The client ID for API authentication.
19
37
  # @return [String, nil] The client ID or `nil` if not set.
20
38
  attr_accessor :client_id
@@ -39,7 +57,55 @@ module DhanHQ
39
57
  attr_writer :base_url
40
58
 
41
59
  # Whether to use the sandbox environment.
42
- attr_accessor :sandbox
60
+ #
61
+ # Backward-compatible facade over {#mode}: setting it to +true+ switches to
62
+ # full sandbox mode, +false+ back to live. Prefer {#mode} for new code — it
63
+ # also supports +:hybrid+ (live market data + sandbox execution).
64
+ #
65
+ # rubocop:disable Naming/PredicateMethod -- legacy attr_accessor-style API,
66
+ # kept as a boolean mirror of +mode+ for every caller written before :hybrid.
67
+ # @return [Boolean] True when every request targets the sandbox host.
68
+ def sandbox
69
+ mode == :sandbox
70
+ end
71
+ # rubocop:enable Naming/PredicateMethod
72
+
73
+ def sandbox=(value)
74
+ self.mode = value ? :sandbox : :live
75
+ end
76
+
77
+ # The environment the client runs in: +:live+, +:sandbox+, or +:hybrid+.
78
+ #
79
+ # Set via +DHAN_MODE+ or in {DhanHQ.configure}. {#sandbox=} remains as an
80
+ # alias for switching between +:live+ and +:sandbox+.
81
+ #
82
+ # @return [Symbol]
83
+ attr_reader :mode
84
+
85
+ # @param value [Symbol, String] One of {MODES} (case-insensitive).
86
+ # @raise [ArgumentError] When the value is not a known mode.
87
+ def mode=(value)
88
+ @mode = coerce_mode!(value)
89
+ end
90
+
91
+ # Per-tier environment overrides applied on top of {#mode}.
92
+ #
93
+ # Keys are API tier symbols (+:order_api+, +:data_api+, +:quote_api+,
94
+ # +:option_chain+, +:non_trading_api+, +:global_stocks_api+); values are
95
+ # +:live+ or +:sandbox+. Useful to, say, keep fund limits on production while
96
+ # everything else runs on the sandbox in hybrid mode:
97
+ #
98
+ # DhanHQ.configure do |c|
99
+ # c.mode = :hybrid
100
+ # c.environment_overrides = { non_trading_api: :live }
101
+ # end
102
+ #
103
+ # @return [Hash{Symbol => Symbol}]
104
+ attr_reader :environment_overrides
105
+
106
+ def environment_overrides=(value)
107
+ @environment_overrides = coerce_environment_overrides(value)
108
+ end
43
109
 
44
110
  # When true, state-changing requests (order placement, modification,
45
111
  # cancellation, position exits, trading controls) are validated and logged but
@@ -176,15 +242,54 @@ module DhanHQ
176
242
  # is nil or the default production URL, returns {SANDBOX_URL}.
177
243
  # @return [String]
178
244
  def base_url
179
- if sandbox? && (@base_url.nil? || @base_url == BASE_URL)
245
+ base_url_for(nil)
246
+ end
247
+
248
+ # Returns the base URL for requests on the given API tier, honouring
249
+ # {#mode}, {#environment_overrides}, and an explicit {#base_url} override.
250
+ #
251
+ # In +:hybrid+ mode data tiers (see {HYBRID_LIVE_API_TYPES}) resolve to the
252
+ # production host while trading and account tiers resolve to the sandbox
253
+ # host, which is what lets a strategy read live prices while placing paper
254
+ # orders.
255
+ #
256
+ # @param api_type [Symbol, nil] API tier the request belongs to. +nil+ uses
257
+ # the mode-level default (+:sandbox+ mode resolves to the sandbox host,
258
+ # +:live+ and +:hybrid+ to production).
259
+ # @return [String]
260
+ def base_url_for(api_type)
261
+ if environment_for(api_type) == :sandbox && (@base_url.nil? || @base_url == BASE_URL)
180
262
  SANDBOX_URL
181
263
  else
182
264
  @base_url || BASE_URL
183
265
  end
184
266
  end
185
267
 
268
+ # The effective environment (+:live+ or +:sandbox+) for requests on the
269
+ # given API tier. Explicit {#environment_overrides} win over the {#mode}
270
+ # defaults.
271
+ #
272
+ # @param api_type [Symbol, nil]
273
+ # @return [Symbol]
274
+ def environment_for(api_type)
275
+ key = api_type&.to_sym
276
+ return @environment_overrides[key] if key && @environment_overrides.key?(key)
277
+
278
+ case mode
279
+ when :sandbox then :sandbox
280
+ when :hybrid then HYBRID_LIVE_API_TYPES.include?(key) ? :live : :sandbox
281
+ else :live
282
+ end
283
+ end
284
+
186
285
  def sandbox?
187
- @sandbox == true
286
+ mode == :sandbox
287
+ end
288
+
289
+ # True when {#mode} is +:hybrid+ — live market data, sandbox execution.
290
+ # @return [Boolean]
291
+ def hybrid?
292
+ mode == :hybrid
188
293
  end
189
294
 
190
295
  # @return [Boolean] True when state-changing requests should be simulated.
@@ -228,6 +333,10 @@ module DhanHQ
228
333
  default: true)
229
334
  end
230
335
  @base_url = attrs[:base_url] || ENV.fetch("DHAN_BASE_URL", nil)
336
+ @environment_overrides = coerce_environment_overrides(
337
+ attrs[:environment_overrides] || ENV.fetch("DHAN_ENV_OVERRIDES", nil)
338
+ )
339
+ @mode = resolve_mode(attrs)
231
340
  @ws_version = (attrs[:ws_version] || ENV.fetch("DHAN_WS_VERSION", 2)).to_i
232
341
  @ws_order_url = attrs[:ws_order_url] || ENV.fetch("DHAN_WS_ORDER_URL", nil)
233
342
  @ws_market_feed_url = attrs[:ws_market_feed_url] || ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
@@ -242,6 +351,46 @@ module DhanHQ
242
351
 
243
352
  private
244
353
 
354
+ # Resolves the initial mode from attributes and ENV.
355
+ #
356
+ # Precedence: an explicit +:mode+ attribute wins, then +DHAN_MODE+, then the
357
+ # legacy +sandbox+ attribute / +DHAN_SANDBOX+ flag, then +:live+.
358
+ def resolve_mode(attrs)
359
+ raw_mode = attrs.key?(:mode) ? attrs[:mode] : ENV.fetch("DHAN_MODE", nil)
360
+ return coerce_mode!(raw_mode) if raw_mode
361
+
362
+ sandbox_flag = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
363
+ sandbox_flag ? :sandbox : :live
364
+ end
365
+
366
+ # Normalises a mode value, raising on anything outside {MODES}.
367
+ def coerce_mode!(value)
368
+ mode = value.to_s.strip.downcase.to_sym
369
+ raise ArgumentError, "unknown mode #{value.inspect}: expected one of #{MODES.join(", ")}" unless MODES.include?(mode)
370
+
371
+ mode
372
+ end
373
+
374
+ # Valid targets for an environment override.
375
+ OVERRIDE_TARGETS = %i[live sandbox].freeze
376
+ private_constant :OVERRIDE_TARGETS
377
+
378
+ # Normalises an environment-overrides hash: symbol keys, :live/:sandbox values.
379
+ def coerce_environment_overrides(value)
380
+ return {} if value.nil? || value == ""
381
+
382
+ value = value.split(",").to_h { |pair| pair.split("=") } if value.is_a?(String)
383
+ raise ArgumentError, "environment_overrides must be a Hash (tier => :live/:sandbox)" unless value.is_a?(Hash)
384
+
385
+ value.each_with_object({}) do |(tier, env), out|
386
+ key = tier.to_s.strip.to_sym
387
+ target = env.to_s.strip.downcase.to_sym
388
+ raise ArgumentError, "environment override for #{key} must be :live or :sandbox (got #{env.inspect})" unless OVERRIDE_TARGETS.include?(target)
389
+
390
+ out[key] = target
391
+ end
392
+ end
393
+
245
394
  # Reads a boolean-ish environment variable, falling back to +default+ when unset.
246
395
  def env_flag(name, default:)
247
396
  raw = ENV.fetch(name, nil)
@@ -130,6 +130,63 @@ module DhanHQ
130
130
  # Backward-compatible alias kept for existing SDK usage.
131
131
  Instrument = InstrumentType
132
132
 
133
+ # ScanX data API enums (POST /v2/data/* — company info, market movers,
134
+ # news headlines, technical indicators).
135
+ module ScanX
136
+ # Exchange segments accepted by POST /v2/data/companyinfo.
137
+ COMPANY_INFO_SEGMENTS = %w[NSE_EQ BSE_EQ].freeze
138
+ # Metric sections returned by POST /v2/data/companyinfo (CO = company
139
+ # overview, RATIOS = valuation/profitability + industry benchmarks,
140
+ # SHP = shareholding pattern).
141
+ COMPANY_INFO_METRICS = %w[CO RATIOS SHP].freeze
142
+ # Instrument kinds accepted by POST /v2/data/companyinfo.
143
+ COMPANY_INFO_INSTRUMENTS = %w[EQUITY].freeze
144
+
145
+ # Exchange segments accepted by POST /v2/data/marketmovers.
146
+ MARKET_MOVERS_SEGMENTS = %w[NSE_FNO BSE_FNO NSE_COMM MCX_COMM NSE_EQ BSE_EQ].freeze
147
+ # Instrument kinds ranked by POST /v2/data/marketmovers.
148
+ MARKET_MOVERS_INSTRUMENTS = %w[OPTIDX OPTSTK OPTFUT FUTIDX FUTSTK FUTCOM EQUITY].freeze
149
+ # Ranking categories for POST /v2/data/marketmovers.
150
+ MARKET_MOVERS_CATEGORIES =
151
+ %w[HIGHEST_OI OI_GAINERS OI_LOSERS TOP_VOLUME PRICE_GAINERS PRICE_LOSERS].freeze
152
+ # Equity universes accepted by POST /v2/data/marketmovers (required when
153
+ # instrument is EQUITY).
154
+ MARKET_MOVERS_UNIVERSES = %w[
155
+ ALL FNO_STOCKS NIFTY_50 NIFTY_BANK FINNIFTY INDIA_VIX NIFTY_MIDCAP NIFTY_NEXT_50
156
+ NIFTY_SMALLCAP_50 NIFTY_MID_CAP_50 NIFTY_100 NIFTY_200 NIFTY_500 NIFTY_MIDCAP_100
157
+ NIFTY_MIDCAP_150 NIFTY_SMALLCAP_100 NIFTY_SMALLCAP_250 NIFTY_MICROCAP_250 NIFTY_AUTO
158
+ NIFTY_PRIVATE_BANK NIFTY_FMCG NIFTY_ENERGY NIFTY_INFRA NIFTY_IT NIFTY_MEDIA NIFTY_METAL
159
+ NIFTY_MNC NIFTY_PHARMA NIFTY_PSU_BANK NIFTY_REALTY NIFTY_SERVICE_SECTOR NIFTY_CUNSUMPTION
160
+ GIFT_NIFTY SENSEX BSE_100 BSE_200 BSE_500 BSE_150_MIDCAP BSE_250_SMALLCAP
161
+ BSE_250_LARGE_MID BSE_400_MID_SMALL BSE_BANKEX BSE_AUTO BSE_CAPITAL_GOODS
162
+ BSE_CONSUMER_DURABLES BSE_ENERGY BSE_FINANCE BSE_FMCG BSE_HEALTHCARE BSE_INDIA_MFG
163
+ BSE_INDUSTRIALS BSE_IPO BSE_IT BSE_METALS BSE_OIL_AND_GAS BSE_POWER BSE_PSU BSE_TELECOM
164
+ ].freeze
165
+
166
+ # News categories accepted by POST /v2/data/newsheadline.
167
+ NEWS_CATEGORIES = %w[
168
+ ALL COMPANIES EQUITY_MARKETS DEBT_MARKETS IPO GLOBAL INDIAN_ECONOMY GLOBAL_ECONOMY
169
+ CURRENCY COMMODITIES CRYPTOCURRENCIES GOVERNMENT_POLICY_AND_REGULATION INFRASTRUCTURE
170
+ INTERNATIONAL_TRADE INVESTMENT_IDEAS REAL_ESTATE STARTUPS TECHNOLOGY MUTUAL_FUNDS
171
+ ].freeze
172
+ # News scoping options (portfolio/watchlist) for POST /v2/data/newsheadline.
173
+ NEWS_UNIVERSES = %w[PORTFOLIO WATCHLIST].freeze
174
+
175
+ # Exchange segments accepted by POST /v2/data/technical.
176
+ TECHNICAL_SEGMENTS = %w[NSE_EQ IDX_I].freeze
177
+ # Instrument kinds accepted by POST /v2/data/technical.
178
+ TECHNICAL_INSTRUMENTS = %w[INDEX EQUITY].freeze
179
+ # Candle timeframes for POST /v2/data/technical (1/5/15 minute, D = daily).
180
+ TECHNICAL_TIMEFRAMES = %w[1 5 15 D].freeze
181
+ # Indicator identifiers computed by POST /v2/data/technical.
182
+ TECHNICAL_INDICATORS = %w[
183
+ SMA_5 SMA_10 SMA_20 SMA_50 SMA_100 SMA_200
184
+ EMA_5 EMA_10 EMA_20 EMA_50 EMA_100 EMA_200
185
+ RSI_14 MACD_HIST STOCH STOCHRSI_14 ATR_14 ADX_14 UO ROC WILLR
186
+ PIVOT_CLASSIC PIVOT_FIBONACCI PIVOT_CAMARILLA
187
+ ].freeze
188
+ end
189
+
133
190
  # Minute intervals allowed by POST /v2/charts/intraday (charts annexure).
134
191
  module ChartInterval
135
192
  ONE = "1"
@@ -536,7 +593,8 @@ module DhanHQ
536
593
  "/v2/marketfeed/",
537
594
  "/v2/optionchain",
538
595
  "/v2/instrument/",
539
- "/v2/charts"
596
+ "/v2/charts",
597
+ "/v2/data"
540
598
  ].freeze
541
599
 
542
600
  # Path prefixes for which the request body (POST/PUT/PATCH) must include dhanClientId.
@@ -555,6 +613,7 @@ module DhanHQ
555
613
  /v2/killswitch
556
614
  /v2/ip
557
615
  /v2/globalstocks
616
+ /v2/data/newsheadline
558
617
  ].freeze
559
618
 
560
619
  # Path prefixes whose non-GET requests change real account state — they place,
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/data/companyinfo (ScanX company fundamentals).
6
+ #
7
+ # securityId (digits), exchangeSegment (NSE_EQ/BSE_EQ), instrument (EQUITY),
8
+ # metrics (one or more of CO / RATIOS / SHP).
9
+ class CompanyInfoContract < BaseContract
10
+ params do
11
+ required(:security_id).filled(:string)
12
+ required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::COMPANY_INFO_SEGMENTS)
13
+ required(:instrument).filled(:string, included_in?: Constants::ScanX::COMPANY_INFO_INSTRUMENTS)
14
+ required(:metrics).filled(:array, min_size?: 1) do
15
+ each(:str?, included_in?: Constants::ScanX::COMPANY_INFO_METRICS)
16
+ end
17
+ end
18
+
19
+ rule(:security_id) do
20
+ key.failure("must be a numeric security id string") unless value.to_s.match?(/\A\d+\z/)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validates request for POST /v2/data/marketmovers (ScanX ranked instruments).
8
+ #
9
+ # exchangeSegment, instrument[] (options / futures / equity kinds, no mixing
10
+ # groups), category, limit (1-100). Expiry is required for options/futures;
11
+ # universe is required for EQUITY and ignored otherwise.
12
+ class MarketMoversContract < BaseContract
13
+ DERIVATIVE_INSTRUMENTS = %w[OPTIDX OPTSTK OPTFUT FUTIDX FUTSTK FUTCOM].freeze
14
+ private_constant :DERIVATIVE_INSTRUMENTS
15
+
16
+ params do
17
+ required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_SEGMENTS)
18
+ required(:instrument).filled(:array, min_size?: 1) do
19
+ each(:str?, included_in?: Constants::ScanX::MARKET_MOVERS_INSTRUMENTS)
20
+ end
21
+ required(:category).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_CATEGORIES)
22
+ required(:limit).filled(:integer, included_in?: 1..100)
23
+ optional(:expiry).filled(:string)
24
+ optional(:universe).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_UNIVERSES)
25
+ end
26
+
27
+ rule(:expiry) do
28
+ next unless value.is_a?(String)
29
+
30
+ unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
31
+ key.failure("must be in YYYY-MM-DD format")
32
+ next
33
+ end
34
+
35
+ Date.parse(value)
36
+ rescue StandardError
37
+ key.failure("must be a valid date")
38
+ end
39
+
40
+ # Expiry is required when ranking derivatives; universe is required for EQUITY.
41
+ rule(:instrument, :expiry) do
42
+ instruments = Array(values[:instrument]).map(&:to_s)
43
+ key(:expiry).failure("is required when instrument includes options or futures") if derivative?(instruments) && values[:expiry].to_s.empty?
44
+ end
45
+
46
+ rule(:instrument, :universe) do
47
+ instruments = Array(values[:instrument]).map(&:to_s)
48
+ key(:universe).failure("is required when instrument is EQUITY") if instruments.include?(Constants::InstrumentType::EQUITY) && values[:universe].to_s.empty?
49
+ end
50
+
51
+ # True when any requested instrument is an option or future.
52
+ def derivative?(instruments)
53
+ instruments.any? { |i| DERIVATIVE_INSTRUMENTS.include?(i) }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/data/newsheadline (ScanX live news).
6
+ #
7
+ # categories[] (ALL or any news category), limit (1-50), optional universe
8
+ # (PORTFOLIO / WATCHLIST) and stock_list[] of security ids. dhanClientId is
9
+ # injected by the client layer and is not validated here.
10
+ class NewsHeadlineContract < BaseContract
11
+ params do
12
+ required(:categories).filled(:array, min_size?: 1) do
13
+ each(:str?, included_in?: Constants::ScanX::NEWS_CATEGORIES)
14
+ end
15
+ required(:limit).filled(:integer, included_in?: 1..50)
16
+ optional(:universe).filled(:string, included_in?: Constants::ScanX::NEWS_UNIVERSES)
17
+ optional(:stock_list).filled(:array) { each(:str?) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Contracts
5
+ # Validates request for POST /v2/data/technical (ScanX technical indicators).
6
+ #
7
+ # securityId (digits), exchangeSegment (NSE_EQ/IDX_I), instrument
8
+ # (INDEX/EQUITY), timeframe (1/5/15/D), indicators[] (e.g. SMA_20, RSI_14).
9
+ class TechnicalDataContract < BaseContract
10
+ params do
11
+ required(:security_id).filled(:string)
12
+ required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::TECHNICAL_SEGMENTS)
13
+ required(:instrument).filled(:string, included_in?: Constants::ScanX::TECHNICAL_INSTRUMENTS)
14
+ required(:timeframe).filled(:string, included_in?: Constants::ScanX::TECHNICAL_TIMEFRAMES)
15
+ required(:indicators).filled(:array, min_size?: 1) do
16
+ each(:str?, included_in?: Constants::ScanX::TECHNICAL_INDICATORS)
17
+ end
18
+ end
19
+
20
+ rule(:security_id) do
21
+ key.failure("must be a numeric security id string") unless value.to_s.match?(/\A\d+\z/)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -64,21 +64,6 @@ module DhanHQ
64
64
 
65
65
  private
66
66
 
67
- # Performs an API request.
68
- #
69
- # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
70
- # @param endpoint [String] API endpoint
71
- # @param params [Hash] Request parameters
72
- # @return [Hash, Array] The parsed API response
73
- # @raise [DhanHQ::Error] If an API error occurs.
74
- def request(method, endpoint, params: {})
75
- formatted_params = format_params(endpoint, params)
76
-
77
- response = client.request(method, build_path(endpoint), formatted_params)
78
-
79
- handle_response(response)
80
- end
81
-
82
67
  # Construct the complete API URL
83
68
  #
84
69
  # @param endpoint [String] API endpoint
@@ -40,14 +40,31 @@ module DhanHQ
40
40
  class << self
41
41
  attr_reader :defined_attributes
42
42
 
43
- # Registers the set of attributes for this model
43
+ # Registers the set of attributes for this model. Also defines the
44
+ # snake_case and camelCase readers once per class, so instances stop
45
+ # allocating per-record singleton methods.
44
46
  #
45
47
  # @param args [Array<Symbol, String>] A list of attribute names
46
48
  def attributes(*args)
47
49
  @defined_attributes ||= []
48
- @defined_attributes.concat(args.map(&:to_s))
50
+ new_attrs = args.map(&:to_s)
51
+ @defined_attributes.concat(new_attrs)
52
+ define_accessors(new_attrs)
53
+ @defined_attributes
49
54
  end
50
55
 
56
+ # Defines one snake_case and one camelCase reader per attribute, reading
57
+ # through @attributes so every instance stays in sync. Called from
58
+ # {attributes}; private because it is a declaration helper.
59
+ def define_accessors(attrs)
60
+ attrs.each do |attr|
61
+ camel = attr.to_s.camelize(:lower)
62
+ define_method(attr) { @attributes[attr] }
63
+ define_method(camel) { @attributes[attr] }
64
+ end
65
+ end
66
+ private :define_accessors
67
+
51
68
  # Provide a default API type, can be overridden by child classes
52
69
  #
53
70
  # e.g., def self.api_type; :data_api; end
@@ -64,6 +81,13 @@ module DhanHQ
64
81
  @api ||= BaseAPI.new(api_type: api_type)
65
82
  end
66
83
 
84
+ # Parameter key style used by {#to_request_params}: :camel (camelCase)
85
+ # by default. Models whose API takes TitleCase keys (the option chain)
86
+ # override this to :title.
87
+ def param_key_style
88
+ :camel
89
+ end
90
+
67
91
  ##
68
92
  # Returns the API resource used by collection methods.
69
93
  #
@@ -91,6 +115,8 @@ module DhanHQ
91
115
  # Validate attributes before creating a new instance
92
116
  def validate_attributes(attributes)
93
117
  contract = validation_contract
118
+ return true if contract.nil?
119
+
94
120
  result = contract.call(attributes)
95
121
 
96
122
  raise ArgumentError, "Validation failed: #{result.errors.to_h}" if result.failure?
@@ -132,8 +158,6 @@ module DhanHQ
132
158
  # @param attributes [Hash] The attributes of the resource
133
159
  # @return [DhanHQ::BaseModel, DhanHQ::ErrorObject] The resource or error object
134
160
  def create(attributes)
135
- # validate_params!(attributes, validation_contract)
136
-
137
161
  response = resource.post("", params: attributes)
138
162
  build_from_response(response)
139
163
  end
@@ -242,7 +266,7 @@ module DhanHQ
242
266
  if self.class.defined_attributes&.include?("dhan_client_id") && !attrs[:dhan_client_id] && DhanHQ.configuration.client_id
243
267
  attrs[:dhan_client_id] = DhanHQ.configuration.client_id
244
268
  end
245
- optionchain_api? ? titleize_keys(attrs) : camelize_keys(attrs)
269
+ self.class.param_key_style == :title ? titleize_keys(attrs) : camelize_keys(attrs)
246
270
  end
247
271
 
248
272
  # Identifier inferred from the loaded attributes.
@@ -254,19 +278,15 @@ module DhanHQ
254
278
  id_value&.to_s
255
279
  end
256
280
 
257
- # Dynamically assign attributes as methods
281
+ # Assigns each declared attribute to an instance variable. The accessor
282
+ # methods themselves are defined once per class (see .attributes); the
283
+ # ivars exist for {AttributeHelper#inspect} and debugging.
258
284
  def assign_attributes
259
285
  self.class.defined_attributes&.each do |attr|
260
286
  instance_variable_set(:"@#{attr}", @attributes[attr])
261
- define_singleton_method(attr) { instance_variable_get(:"@#{attr}") }
262
- define_singleton_method(attr.to_s.camelize(:lower)) { instance_variable_get(:"@#{attr}") }
263
287
  end
264
288
  end
265
289
 
266
- def optionchain_api?
267
- self.class.name.include?("OptionChain")
268
- end
269
-
270
290
  # Validate attributes using contract
271
291
  def valid?
272
292
  contract_class = validation_contract || self.class.validation_contract
@@ -69,27 +69,5 @@ module DhanHQ
69
69
  instance_vars = self.class.defined_attributes.map { |attr| "#{attr}: #{instance_variable_get(:"@#{attr}")}" }
70
70
  "#<#{self.class.name} #{instance_vars.join(", ")}>"
71
71
  end
72
-
73
- # def format_params(path, params)
74
- # return params unless params.is_a?(Hash)
75
-
76
- # if optionchain_api?(path)
77
- # titleize_keys(params)
78
- # else
79
- # camelize_keys(params)
80
- # end
81
- # end
82
-
83
- # def camelize_keys(hash)
84
- # hash.transform_keys { |key| key.to_s.camelize(:lower) }
85
- # end
86
-
87
- # def titleize_keys(hash)
88
- # hash.transform_keys { |key| key.to_s.titleize.delete(" ") }
89
- # end
90
-
91
- # def optionchain_api?(path)
92
- # path.include?("/optionchain")
93
- # end
94
72
  end
95
73
  end
@@ -55,6 +55,13 @@ module DhanHQ
55
55
  end
56
56
 
57
57
  class << self
58
+ ##
59
+ # The option chain API takes TitleCase keys (e.g. "UnderlyingScripValue");
60
+ # see BaseModel.param_key_style.
61
+ def param_key_style
62
+ :title
63
+ end
64
+
58
65
  ##
59
66
  # Provides a shared instance of the OptionChain resource.
60
67
  #
@@ -491,12 +491,12 @@ module DhanHQ
491
491
  # order = DhanHQ::Models::Order.find("112111182045")
492
492
  # order.modify(validity: "IOC")
493
493
  #
494
- # @raise [RuntimeError] If order ID is missing
494
+ # @raise [DhanHQ::OrderError] If order ID is missing
495
495
  # @raise [DhanHQ::ValidationError] If validation fails for any parameter
496
496
  # @raise [DhanHQ::ModificationLimitError] If this instance has already been modified 25 times (Dhan API cap)
497
497
  # @note Count is per Order instance in this process; a fresh find() resets it.
498
498
  def modify(new_params)
499
- raise "Order ID is required to modify an order" unless id
499
+ raise DhanHQ::OrderError, "Order ID is required to modify an order" unless id
500
500
 
501
501
  count = @modification_count || 0
502
502
  if count >= Constants::RateLimit::ORDER_MODIFICATIONS_PER_ORDER
@@ -537,9 +537,9 @@ module DhanHQ
537
537
  # puts "Failed to cancel order"
538
538
  # end
539
539
  #
540
- # @raise [RuntimeError] If order ID is missing
540
+ # @raise [DhanHQ::OrderError] If order ID is missing
541
541
  def cancel
542
- raise "Order ID is required to cancel an order" unless id
542
+ raise DhanHQ::OrderError, "Order ID is required to cancel an order" unless id
543
543
 
544
544
  DhanHQ.logger&.info("[DhanHQ::Models::Order] Cancelling order #{id}")
545
545
  response = self.class.resource.cancel(id)
@@ -566,9 +566,9 @@ module DhanHQ
566
566
  # puts "Updated status: #{order.order_status}"
567
567
  # puts "Filled: #{order.filled_qty}/#{order.quantity}"
568
568
  #
569
- # @raise [RuntimeError] If order ID is missing
569
+ # @raise [DhanHQ::OrderError] If order ID is missing
570
570
  def refresh
571
- raise "Order ID is required to refresh an order" unless id
571
+ raise DhanHQ::OrderError, "Order ID is required to refresh an order" unless id
572
572
 
573
573
  self.class.find(id)
574
574
  end
@@ -603,7 +603,7 @@ module DhanHQ
603
603
  alias delete destroy
604
604
 
605
605
  def slice_order(params)
606
- raise "Order ID is required to slice an order" unless id
606
+ raise DhanHQ::OrderError, "Order ID is required to slice an order" unless id
607
607
 
608
608
  normalized = snake_case(params)
609
609
  normalized[:dhan_client_id] ||= DhanHQ.configuration&.client_id