DhanHQ 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +23 -0
  3. data/CHANGELOG.md +74 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/GUIDE.md +16 -0
  6. data/README.md +54 -1
  7. data/docs/CONFIGURATION.md +38 -14
  8. data/docs/RELEASE_GUIDE.md +1 -1
  9. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  10. data/lib/DhanHQ/client.rb +53 -34
  11. data/lib/DhanHQ/concerns/bang_writes.rb +69 -0
  12. data/lib/DhanHQ/concerns/tracked_writes.rb +56 -0
  13. data/lib/DhanHQ/configuration.rb +20 -1
  14. data/lib/DhanHQ/core/base_model.rb +6 -13
  15. data/lib/DhanHQ/deprecation.rb +62 -0
  16. data/lib/DhanHQ/models/alert_order.rb +8 -1
  17. data/lib/DhanHQ/models/forever_order.rb +9 -0
  18. data/lib/DhanHQ/models/global_stocks/order.rb +9 -0
  19. data/lib/DhanHQ/models/iceberg_order.rb +9 -0
  20. data/lib/DhanHQ/models/multi_order.rb +7 -0
  21. data/lib/DhanHQ/models/order.rb +28 -0
  22. data/lib/DhanHQ/models/pnl_exit.rb +7 -0
  23. data/lib/DhanHQ/models/super_order.rb +9 -0
  24. data/lib/DhanHQ/models/twap_order.rb +9 -0
  25. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  26. data/lib/DhanHQ/version.rb +1 -1
  27. data/lib/DhanHQ/write_result.rb +163 -0
  28. data/lib/dhan_hq.rb +9 -0
  29. data/skills/dhanhq-ruby/SKILL.md +208 -0
  30. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  31. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  32. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  33. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  34. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  35. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  36. data/skills/dhanhq-ruby/examples/order_management.rb +112 -0
  37. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  38. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  39. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  40. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  41. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  42. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  43. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  44. data/skills/dhanhq-ruby/references/funds.md +67 -0
  45. data/skills/dhanhq-ruby/references/instruments.md +91 -0
  46. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  47. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  48. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  49. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  50. data/skills/dhanhq-ruby/references/orders.md +203 -0
  51. data/skills/dhanhq-ruby/references/portfolio.md +100 -0
  52. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  53. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  54. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  55. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  56. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  57. metadata +36 -2
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../write_result"
4
+
5
+ module DhanHQ
6
+ module Concerns
7
+ # Generates bang variants of write methods that raise instead of returning a falsy
8
+ # value or an {DhanHQ::ErrorObject}.
9
+ #
10
+ # Each generated method calls its non-bang counterpart and passes the result through
11
+ # {DhanHQ::WriteResult.unwrap!}, so the two cannot drift: there is no duplicated
12
+ # request building, validation or logging. The non-bang methods are untouched, which
13
+ # is what makes this additive — existing callers keep the return values they were
14
+ # written against.
15
+ #
16
+ # @example
17
+ # class Order < BaseModel
18
+ # extend DhanHQ::Concerns::BangWrites
19
+ #
20
+ # bang_class_writes :place # defines Order.place!
21
+ # bang_writes :modify, :cancel # defines #modify! and #cancel!
22
+ # end
23
+ #
24
+ # Order.place!(params) # => Order, or raises DhanHQ::OrderError
25
+ # order.cancel! # => true, or raises DhanHQ::OrderError
26
+ module BangWrites
27
+ # Defines `<name>!` instance methods.
28
+ #
29
+ # @param names [Array<Symbol>] Existing instance write methods to wrap.
30
+ # @param error_class [Class] Exception the generated methods raise.
31
+ # @return [void]
32
+ def bang_writes(*names, error_class: DhanHQ::OrderError)
33
+ names.each { |name| include bang_write_module(name, error_class) }
34
+ end
35
+
36
+ # Defines `<name>!` class methods.
37
+ #
38
+ # @param names [Array<Symbol>] Existing class write methods to wrap.
39
+ # @param error_class [Class] Exception the generated methods raise.
40
+ # @return [void]
41
+ def bang_class_writes(*names, error_class: DhanHQ::OrderError)
42
+ names.each { |name| singleton_class.include bang_write_module(name, error_class) }
43
+ end
44
+
45
+ private
46
+
47
+ # Built as a module rather than defined directly, so a hand-written `place!` in
48
+ # the class body still overrides the generated one and can call `super`.
49
+ def bang_write_module(name, error_class)
50
+ Module.new do
51
+ define_method(:"#{name}!") do |*args, **kwargs, &block|
52
+ # A caller already using the bang variant does not need a deprecation
53
+ # notice telling them to use the bang variant.
54
+ result = DhanHQ::WriteResult.suppressing_deprecation do
55
+ public_send(name, *args, **kwargs, &block)
56
+ end
57
+
58
+ DhanHQ::WriteResult.unwrap!(
59
+ result,
60
+ operation: DhanHQ::WriteResult.operation_label(self, name),
61
+ error_class: error_class,
62
+ errors: DhanHQ::WriteResult.errors_from(self)
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../write_result"
4
+
5
+ module DhanHQ
6
+ module Concerns
7
+ # Observes non-bang write methods and reports, once per call site, when one returns
8
+ # a failure shape that 4.0.0 will change.
9
+ #
10
+ # Uses +prepend+ rather than +include+ because these methods are defined directly on
11
+ # the model classes: an included module sits behind the class in the ancestor chain
12
+ # and would never be reached. A prepended one sits in front, so +super+ runs the real
13
+ # method and the result passes through untouched.
14
+ #
15
+ # This layer only observes. It never changes a return value, never raises, and goes
16
+ # quiet once a call site has been migrated to the bang variant.
17
+ #
18
+ # @example
19
+ # class Order < BaseModel
20
+ # extend DhanHQ::Concerns::TrackedWrites
21
+ #
22
+ # track_class_writes :place # warns when Order.place returns nil
23
+ # track_writes :modify, :cancel # warns when #cancel returns false
24
+ # end
25
+ module TrackedWrites
26
+ # Observes `<name>` instance methods.
27
+ #
28
+ # @param names [Array<Symbol>] Existing instance write methods to observe.
29
+ # @return [void]
30
+ def track_writes(*names)
31
+ names.each { |name| prepend tracking_module(name) }
32
+ end
33
+
34
+ # Observes `<name>` class methods.
35
+ #
36
+ # @param names [Array<Symbol>] Existing class write methods to observe.
37
+ # @return [void]
38
+ def track_class_writes(*names)
39
+ names.each { |name| singleton_class.prepend tracking_module(name) }
40
+ end
41
+
42
+ private
43
+
44
+ def tracking_module(name)
45
+ Module.new do
46
+ define_method(name) do |*args, **kwargs, &block|
47
+ DhanHQ::WriteResult.report_ambiguous_failure(
48
+ super(*args, **kwargs, &block),
49
+ operation: DhanHQ::WriteResult.operation_label(self, name)
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -60,7 +60,7 @@ module DhanHQ
60
60
  # /v2/orders that times out may well have reached the exchange, and retrying it
61
61
  # can place a second, duplicate order. With this off, the transient error is
62
62
  # raised to the caller, who can reconcile using the correlation id (see
63
- # {#auto_correlation_id} and +DhanHQ::Models::Order.find_by_correlation_id+)
63
+ # {#auto_correlation_id} and +DhanHQ::Models::Order.find_by_correlation+)
64
64
  # before deciding to resubmit.
65
65
  #
66
66
  # Read requests are always retried regardless of this setting.
@@ -69,6 +69,19 @@ module DhanHQ
69
69
  # @return [Boolean]
70
70
  attr_accessor :retry_non_idempotent_writes
71
71
 
72
+ # Whether to log a deprecation notice, once per call site, when a non-bang write
73
+ # method reports failure through +nil+, +false+ or a {DhanHQ::ErrorObject}.
74
+ #
75
+ # Those contracts disagree today and will unify on {DhanHQ::ErrorObject} in 4.0.0,
76
+ # which is truthy — so an `if result` failure branch written against +nil+ or
77
+ # +false+ will silently invert. The notice names the call sites that need moving to
78
+ # a bang variant before then.
79
+ #
80
+ # Defaults to +true+: a notice nobody sees finds nothing. Set to +false+ once the
81
+ # call sites are migrated, or via +DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false+.
82
+ # @return [Boolean]
83
+ attr_accessor :warn_on_ambiguous_write_failure
84
+
72
85
  # Whether to generate a +correlationId+ for order placements that do not carry
73
86
  # one. The correlation id is the only way to answer "did my order actually go
74
87
  # through?" after a timeout, via GET /v2/orders/external/{correlation-id}.
@@ -175,6 +188,11 @@ module DhanHQ
175
188
  @retry_non_idempotent_writes == true
176
189
  end
177
190
 
191
+ # @return [Boolean] True when ambiguous write failures should be reported.
192
+ def warn_on_ambiguous_write_failure?
193
+ @warn_on_ambiguous_write_failure == true
194
+ end
195
+
178
196
  # @return [Boolean] True when a correlation id should be generated for orders.
179
197
  def auto_correlation_id?
180
198
  @auto_correlation_id == true
@@ -193,6 +211,7 @@ module DhanHQ
193
211
  @dry_run = env_flag("DHAN_DRY_RUN", default: false)
194
212
  @retry_non_idempotent_writes = env_flag("DHAN_RETRY_WRITES", default: false)
195
213
  @auto_correlation_id = env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
214
+ @warn_on_ambiguous_write_failure = env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE", default: true)
196
215
  @base_url = ENV.fetch("DHAN_BASE_URL", nil)
197
216
  @ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
198
217
  @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
@@ -193,19 +193,12 @@ module DhanHQ
193
193
  # @return [DhanHQ::BaseModel]
194
194
  # @raise [DhanHQ::Error] When the record cannot be saved.
195
195
  def save!
196
- result = save
197
- return result unless result == false || result.nil? || result.is_a?(DhanHQ::ErrorObject)
198
-
199
- error_details =
200
- if result.is_a?(DhanHQ::ErrorObject)
201
- result.errors
202
- elsif @errors && !@errors.empty?
203
- @errors
204
- else
205
- "Unknown error"
206
- end
207
-
208
- raise DhanHQ::Error, "Failed to save the record: #{error_details}"
196
+ DhanHQ::WriteResult.unwrap!(
197
+ save,
198
+ operation: "#{self.class}#save",
199
+ error_class: DhanHQ::Error,
200
+ errors: @errors
201
+ )
209
202
  end
210
203
 
211
204
  # Delete the resource
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Emits a deprecation notice once per call site per process.
5
+ #
6
+ # A trading process can reject hundreds of orders in a session. A notice that fires
7
+ # on every one of them is noise that gets filtered out, which defeats the purpose —
8
+ # the point is for a maintainer to find the handful of call sites still relying on
9
+ # behaviour that is going to change, then fix them.
10
+ module Deprecation
11
+ @warned = {}
12
+ @mutex = Mutex.new
13
+
14
+ class << self
15
+ # Logs +message+ the first time this +key+ is seen in this process.
16
+ #
17
+ # A command, not a query: callers that need to know whether a notice was emitted
18
+ # should consult {.warned_keys}.
19
+ #
20
+ # @param key [String, Symbol] Identifies the call site, e.g. the operation name.
21
+ # @param message [String] What is deprecated and what to do instead.
22
+ # @return [void]
23
+ def warn_once(key, message)
24
+ return unless first_warning_for?(key)
25
+
26
+ DhanHQ.logger&.warn("[DhanHQ] DEPRECATION: #{message}")
27
+ nil
28
+ end
29
+
30
+ # Keys already warned about, for assertions and diagnostics.
31
+ #
32
+ # @return [Array<String>]
33
+ def warned_keys
34
+ @mutex.synchronize { @warned.keys }
35
+ end
36
+
37
+ # Forgets every emitted notice, so the next occurrence warns again.
38
+ # Intended for test isolation.
39
+ #
40
+ # @return [void]
41
+ def reset!
42
+ @mutex.synchronize { @warned.clear }
43
+ end
44
+
45
+ private
46
+
47
+ # Whether this is the first time +key+ has been seen, recording it either way.
48
+ #
49
+ # The check and the record happen under one lock so two threads hitting the same
50
+ # call site at once cannot both decide they are first.
51
+ #
52
+ # @return [Boolean]
53
+ def first_warning_for?(key)
54
+ @mutex.synchronize do
55
+ next false if @warned.key?(key.to_s)
56
+
57
+ @warned[key.to_s] = true
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -6,6 +6,13 @@ 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
+ extend DhanHQ::Concerns::BangWrites
10
+ extend DhanHQ::Concerns::TrackedWrites
11
+
12
+ track_class_writes :create, :modify
13
+
14
+ bang_class_writes :create, :modify
15
+
9
16
  include Concerns::ApiResponseHandler
10
17
 
11
18
  HTTP_PATH = "/v2/alerts/orders"
@@ -96,7 +103,7 @@ module DhanHQ
96
103
  handle_api_response(response, success_key: new_record? ? "alertId" : nil)
97
104
  end
98
105
 
99
- def destroy # rubocop:disable Naming/PredicateMethod
106
+ def destroy
100
107
  return false if new_record?
101
108
 
102
109
  response = self.class.resource.delete(id)
@@ -66,6 +66,15 @@ module DhanHQ
66
66
  # orders.each { |order| puts order.order_status }
67
67
  #
68
68
  class ForeverOrder < BaseModel
69
+ extend DhanHQ::Concerns::BangWrites
70
+ extend DhanHQ::Concerns::TrackedWrites
71
+
72
+ track_class_writes :create
73
+ track_writes :modify, :cancel
74
+
75
+ bang_class_writes :create
76
+ bang_writes :modify, :cancel
77
+
69
78
  include Concerns::ApiResponseHandler
70
79
 
71
80
  attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
@@ -36,6 +36,15 @@ module DhanHQ
36
36
  # end
37
37
  #
38
38
  class Order < BaseModel
39
+ extend DhanHQ::Concerns::BangWrites
40
+ extend DhanHQ::Concerns::TrackedWrites
41
+
42
+ track_class_writes :place
43
+ track_writes :modify, :cancel
44
+
45
+ bang_class_writes :place
46
+ bang_writes :modify, :cancel
47
+
39
48
  HTTP_PATH = "/v2/globalstocks/orders"
40
49
 
41
50
  attributes :dhan_client_id, :order_id, :exchange_order_id, :correlation_id,
@@ -48,6 +48,15 @@ module DhanHQ
48
48
  # orders = DhanHQ::Models::IcebergOrder.all
49
49
  #
50
50
  class IcebergOrder < BaseModel
51
+ extend DhanHQ::Concerns::BangWrites
52
+ extend DhanHQ::Concerns::TrackedWrites
53
+
54
+ track_class_writes :create
55
+ track_writes :modify, :cancel
56
+
57
+ bang_class_writes :create
58
+ bang_writes :modify, :cancel
59
+
51
60
  include Concerns::ApiResponseHandler
52
61
 
53
62
  attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
@@ -26,6 +26,13 @@ module DhanHQ
26
26
  # result.for_sequence("1").order_id
27
27
  #
28
28
  class MultiOrder < BaseModel
29
+ extend DhanHQ::Concerns::BangWrites
30
+ extend DhanHQ::Concerns::TrackedWrites
31
+
32
+ track_class_writes :place
33
+
34
+ bang_class_writes :place
35
+
29
36
  HTTP_PATH = "/v2/alerts/multi/orders"
30
37
 
31
38
  attributes :orders
@@ -46,6 +46,15 @@ module DhanHQ
46
46
  # puts "Pending orders: #{pending_orders.count}"
47
47
  #
48
48
  class Order < BaseModel
49
+ extend DhanHQ::Concerns::BangWrites
50
+ extend DhanHQ::Concerns::TrackedWrites
51
+
52
+ track_class_writes :place, :create
53
+ track_writes :modify, :cancel
54
+
55
+ bang_class_writes :place, :create
56
+ bang_writes :modify, :cancel, :refresh
57
+
49
58
  include Concerns::ApiResponseHandler
50
59
 
51
60
  # Attributes eligible for modification requests.
@@ -430,6 +439,25 @@ module DhanHQ
430
439
  order.save # calls resource create or update
431
440
  order
432
441
  end
442
+
443
+ # `create` always returns the built `Order`, even when `#save` returned
444
+ # `false` -- existing callers rely on that to inspect an unsaved order's
445
+ # `errors`, so `create` cannot change. The generated `create!` would
446
+ # therefore call `unwrap!` on an object that is truthy regardless of
447
+ # whether the placement actually succeeded, and never raise. This
448
+ # hand-written override supersedes it (see {BangWrites} for why a
449
+ # class-body definition takes precedence) and unwraps on `persisted?`
450
+ # instead, which reflects whether the API actually accepted the order.
451
+ def create!(params)
452
+ order = DhanHQ::WriteResult.suppressing_deprecation { create(params) }
453
+
454
+ DhanHQ::WriteResult.unwrap!(
455
+ order.persisted? ? order : false,
456
+ operation: DhanHQ::WriteResult.operation_label(self, :create),
457
+ error_class: DhanHQ::OrderError,
458
+ errors: DhanHQ::WriteResult.errors_from(order)
459
+ )
460
+ end
433
461
  end
434
462
 
435
463
  ##
@@ -32,6 +32,13 @@ module DhanHQ
32
32
  # puts response[:pnl_exit_status] # => "DISABLED"
33
33
  #
34
34
  class PnlExit < BaseModel
35
+ extend DhanHQ::Concerns::BangWrites
36
+ extend DhanHQ::Concerns::TrackedWrites
37
+
38
+ track_class_writes :configure, :stop
39
+
40
+ bang_class_writes :configure, :stop
41
+
35
42
  HTTP_PATH = "/v2/pnlExit"
36
43
 
37
44
  attributes :pnl_exit_status, :profit, :loss, :segments, :enable_kill_switch
@@ -48,6 +48,15 @@ module DhanHQ
48
48
  # order.cancel("STOP_LOSS_LEG")
49
49
  #
50
50
  class SuperOrder < BaseModel
51
+ extend DhanHQ::Concerns::BangWrites
52
+ extend DhanHQ::Concerns::TrackedWrites
53
+
54
+ track_class_writes :create
55
+ track_writes :modify, :cancel
56
+
57
+ bang_class_writes :create
58
+ bang_writes :modify, :cancel
59
+
51
60
  include Concerns::ApiResponseHandler
52
61
 
53
62
  attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
@@ -45,6 +45,15 @@ module DhanHQ
45
45
  # order.cancel
46
46
  #
47
47
  class TwapOrder < BaseModel
48
+ extend DhanHQ::Concerns::BangWrites
49
+ extend DhanHQ::Concerns::TrackedWrites
50
+
51
+ track_class_writes :create
52
+ track_writes :modify, :cancel
53
+
54
+ bang_class_writes :create
55
+ bang_writes :modify, :cancel
56
+
48
57
  include Concerns::ApiResponseHandler
49
58
 
50
59
  attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
@@ -51,14 +51,12 @@ module DhanHQ
51
51
  # @param type [Symbol] :equity or :options
52
52
  # @return [true] if all checks pass
53
53
  # @raise [DhanHQ::RiskViolation] on first failure
54
- # rubocop:disable Naming/PredicateMethod
55
54
  def self.run!(instrument:, args:, now: Time.now, type: :equity)
56
55
  run_checks!(CHECKS, instrument, args, now)
57
56
  run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
58
57
  run_checks!(DAILY_CHECKS, instrument, args, now)
59
58
  true
60
59
  end
61
- # rubocop:enable Naming/PredicateMethod
62
60
 
63
61
  def self.run_checks!(checks, instrument, args, now)
64
62
  checks.each do |check|
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "3.2.0"
5
+ VERSION = "3.3.0"
6
6
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Interprets the several shapes a write currently uses to signal failure.
5
+ #
6
+ # The model write methods do not share a return contract. Depending on which class
7
+ # and which failure you hit, a rejected write comes back as +nil+, as +false+, or as
8
+ # a {DhanHQ::ErrorObject} — and {DhanHQ::Models::AlertOrder.modify} can return either
9
+ # of the first two from the same method. A caller cannot write one error branch.
10
+ #
11
+ # Unifying those contracts is a breaking change for the applications that depend on
12
+ # this gem, so it is staged (see CHANGELOG). This module is step one: it puts the
13
+ # knowledge of "what does failure look like" in exactly one place, and backs the bang
14
+ # variants (+place!+, +modify!+, +cancel!+, …) that give callers a single, explicit
15
+ # failure mode to rescue today.
16
+ #
17
+ # @example Opting a call site into explicit failures
18
+ # order = DhanHQ::Models::Order.place!(params) # raises DhanHQ::OrderError
19
+ # # instead of
20
+ # order = DhanHQ::Models::Order.place(params) # nil on failure
21
+ module WriteResult
22
+ # Thread-local flag set while a bang variant is calling through.
23
+ SUPPRESSION_KEY = :dhanhq_suppress_write_deprecation
24
+
25
+ module_function
26
+
27
+ # Whether a write result represents a rejected or failed operation.
28
+ #
29
+ # @param result [Object] Return value of a write method.
30
+ # @return [Boolean]
31
+ def failure?(result)
32
+ result.nil? || result == false || result.is_a?(DhanHQ::ErrorObject)
33
+ end
34
+
35
+ # @param result [Object] Return value of a write method.
36
+ # @return [Boolean]
37
+ def success?(result)
38
+ !failure?(result)
39
+ end
40
+
41
+ # Returns the result, or raises carrying whatever diagnostics the failure held.
42
+ #
43
+ # @param result [Object] Return value of a write method.
44
+ # @param operation [String] Human-readable operation name for the message,
45
+ # e.g. +"DhanHQ::Models::Order.place"+.
46
+ # @param error_class [Class] Exception to raise. Defaults to {DhanHQ::OrderError},
47
+ # which descends from {DhanHQ::Error}, so existing `rescue DhanHQ::Error`
48
+ # handlers still catch it.
49
+ # @param errors [Hash, nil] Validation errors to report when the result itself
50
+ # carries none — typically a model's +errors+ hash.
51
+ # @return [Object] The result, when it represents success.
52
+ # @raise [DhanHQ::Error] When the result represents failure.
53
+ def unwrap!(result, operation:, error_class: DhanHQ::OrderError, errors: nil)
54
+ return result if success?(result)
55
+
56
+ raise error_class, "#{operation} failed: #{describe(result, errors)}"
57
+ end
58
+
59
+ # Reports, once per call site, that a non-bang write signalled failure through a
60
+ # value whose shape is going to change in 4.0.0.
61
+ #
62
+ # Step two of the migration: the notice tells a maintainer which of their call
63
+ # sites still branch on the old return value, so they can move to the bang variant
64
+ # before the non-bang contract unifies on {DhanHQ::ErrorObject}. Returns the result
65
+ # untouched — this observes, it never alters behaviour.
66
+ #
67
+ # Silent when the write succeeded, when the caller opted out via
68
+ # +config.warn_on_ambiguous_write_failure = false+, or when reached through a bang
69
+ # variant (those callers have already migrated — see {.suppressing_deprecation}).
70
+ #
71
+ # @param result [Object] Return value of a non-bang write method.
72
+ # @param operation [String] Operation label from {.operation_label}.
73
+ # @return [Object] The result, unchanged.
74
+ def report_ambiguous_failure(result, operation:)
75
+ return result unless failure?(result)
76
+ return result if suppressed?
77
+ return result unless DhanHQ.configuration&.warn_on_ambiguous_write_failure?
78
+
79
+ DhanHQ::Deprecation.warn_once(
80
+ operation,
81
+ "#{operation} reported failure as #{shape_of(result)}. Write methods return " \
82
+ "nil, false or a DhanHQ::ErrorObject inconsistently today and will all return " \
83
+ "DhanHQ::ErrorObject in 4.0.0, which is truthy — an `if result` failure branch " \
84
+ "will invert. Use #{operation}! to get a DhanHQ::OrderError instead, or set " \
85
+ "config.warn_on_ambiguous_write_failure = false to silence this."
86
+ )
87
+
88
+ result
89
+ end
90
+
91
+ # Runs the block with {.report_ambiguous_failure} disabled on this thread.
92
+ #
93
+ # Used by the bang variants: they call the non-bang method to get its result, and a
94
+ # caller who has already moved to `place!` does not need telling to move to
95
+ # `place!`. Thread-local so a concurrent thread still gets its own notices.
96
+ #
97
+ # @return [Object] The block's value.
98
+ def suppressing_deprecation
99
+ previous = Thread.current[SUPPRESSION_KEY]
100
+ Thread.current[SUPPRESSION_KEY] = true
101
+ yield
102
+ ensure
103
+ Thread.current[SUPPRESSION_KEY] = previous
104
+ end
105
+
106
+ # @return [Boolean]
107
+ def suppressed?
108
+ Thread.current[SUPPRESSION_KEY] == true
109
+ end
110
+
111
+ # Names the shape a failure arrived in, for the notice.
112
+ #
113
+ # @return [String]
114
+ def shape_of(result)
115
+ return "nil" if result.nil?
116
+ return "false" if result == false
117
+
118
+ "a DhanHQ::ErrorObject"
119
+ end
120
+
121
+ # Label identifying the operation that failed, for the exception message.
122
+ #
123
+ # @param receiver [Object] The class (for a class method) or instance.
124
+ # @param name [Symbol] Method name.
125
+ # @return [String] e.g. +"DhanHQ::Models::Order.place"+ or +"…Order#modify"+.
126
+ def operation_label(receiver, name)
127
+ return "#{module_label(receiver)}.#{name}" if receiver.is_a?(Module)
128
+
129
+ "#{module_label(receiver.class)}##{name}"
130
+ end
131
+
132
+ # Reads a module's declared name, falling back to +to_s+ so an anonymous class
133
+ # still yields something readable rather than an object address.
134
+ #
135
+ # @return [String]
136
+ def module_label(mod)
137
+ mod.name || mod.to_s
138
+ end
139
+
140
+ # Validation errors carried by a model instance, when it has any.
141
+ #
142
+ # @param receiver [Object]
143
+ # @return [Hash, nil]
144
+ def errors_from(receiver)
145
+ return nil if receiver.is_a?(Module)
146
+ return nil unless receiver.respond_to?(:errors)
147
+
148
+ receiver.errors
149
+ end
150
+
151
+ # Best available explanation for a failure.
152
+ #
153
+ # @return [String]
154
+ def describe(result, errors = nil)
155
+ return result.errors.to_s if result.is_a?(DhanHQ::ErrorObject)
156
+ return errors.to_s if errors && !errors.empty?
157
+
158
+ # `nil` and `false` carry nothing, so say which of the two it was rather than
159
+ # inventing a cause.
160
+ result.nil? ? "the API returned no record" : "the API rejected the request"
161
+ end
162
+ end
163
+ end
data/lib/dhan_hq.rb CHANGED
@@ -26,11 +26,20 @@ module DhanHQ
26
26
  LOADER = Zeitwerk::Loader.new
27
27
  LOADER.tag = "dhanhq"
28
28
  LOADER.inflector.inflect(
29
+ # Without this, Zeitwerk looks for DhanHQ::Ai while ai.rb defines DhanHQ::AI, so
30
+ # DhanHQ::AI::PromptHelpers raised NameError unless something had already loaded
31
+ # the file by hand — which only mcp/server.rb did.
32
+ "ai" => "AI",
29
33
  "api_helper" => "APIHelper",
30
34
  "auth_api" => "AuthAPI",
31
35
  "base_api" => "BaseAPI",
32
36
  "ip_setup" => "IPSetup",
33
37
  "json_loader" => "JSONLoader",
38
+ # Same failure as "ai" above: mcp.rb defines DhanHQ::MCP while Zeitwerk expected
39
+ # DhanHQ::Mcp, so DhanHQ::MCP::Server raised NameError after a bare `require
40
+ # "dhan_hq"` — it only worked via exe/dhanhq-mcp and lib/dhan_hq/mcp.rb, which
41
+ # require_relative the file directly instead of going through the autoloader.
42
+ "mcp" => "MCP",
34
43
  "ws" => "WS"
35
44
  )
36
45
  LOADER.push_dir(File.join(__dir__, "DhanHQ"), namespace: self)