DhanHQ 3.2.1 → 3.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/GUIDE.md +16 -0
  4. data/README.md +93 -18
  5. data/docs/CONFIGURATION.md +39 -14
  6. data/docs/RAILS_INTEGRATION.md +60 -17
  7. data/docs/RELEASE_GUIDE.md +1 -1
  8. data/docs/TROUBLESHOOTING.md +15 -1
  9. data/lib/DhanHQ/backtest/result.rb +87 -0
  10. data/lib/DhanHQ/backtest/runner.rb +144 -0
  11. data/lib/DhanHQ/backtest/trade.rb +35 -0
  12. data/lib/DhanHQ/backtest.rb +24 -0
  13. data/lib/DhanHQ/concerns/bang_writes.rb +69 -0
  14. data/lib/DhanHQ/concerns/tracked_writes.rb +56 -0
  15. data/lib/DhanHQ/configuration.rb +35 -1
  16. data/lib/DhanHQ/core/base_model.rb +6 -13
  17. data/lib/DhanHQ/deprecation.rb +62 -0
  18. data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
  19. data/lib/DhanHQ/models/alert_order.rb +7 -0
  20. data/lib/DhanHQ/models/forever_order.rb +9 -0
  21. data/lib/DhanHQ/models/global_stocks/order.rb +9 -0
  22. data/lib/DhanHQ/models/iceberg_order.rb +9 -0
  23. data/lib/DhanHQ/models/multi_order.rb +7 -0
  24. data/lib/DhanHQ/models/order.rb +28 -0
  25. data/lib/DhanHQ/models/pnl_exit.rb +7 -0
  26. data/lib/DhanHQ/models/super_order.rb +9 -0
  27. data/lib/DhanHQ/models/twap_order.rb +9 -0
  28. data/lib/DhanHQ/version.rb +1 -1
  29. data/lib/DhanHQ/write_result.rb +163 -0
  30. data/lib/DhanHQ/ws/base_connection.rb +1 -0
  31. data/lib/DhanHQ/ws/connection.rb +1 -0
  32. data/lib/DhanHQ/ws/orders/connection.rb +1 -0
  33. data/lib/DhanHQ/ws.rb +25 -0
  34. data/lib/dhan_hq.rb +5 -0
  35. data/lib/generators/dhanhq/install/install_generator.rb +45 -0
  36. data/lib/generators/dhanhq/install/templates/initializer.rb +18 -0
  37. data/lib/generators/dhanhq/install/templates/market_feed_channel.rb +7 -0
  38. data/lib/generators/dhanhq/install/templates/market_feed_worker.rb +27 -0
  39. data/lib/generators/dhanhq/install/templates/place_order_service.rb +24 -0
  40. data/skills/dhanhq-ruby/references/portfolio.md +15 -8
  41. metadata +30 -13
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Backtest
5
+ # Replays a DhanHQ::Strategy::Base against historical OHLC candles and
6
+ # returns a Result.
7
+ #
8
+ # Both entries and exits fill at the *next* candle's open, never the
9
+ # signal candle's own price — evaluating a signal on a candle and then
10
+ # filling somewhere inside that same candle is look-ahead bias, since the
11
+ # fill price would have to come from before the candle closed (and so
12
+ # before the signal could have fired live). The one exception is the end
13
+ # of the dataset: an open position with no next candle to fill against is
14
+ # force-closed at the last candle's close.
15
+ #
16
+ # Only one position is held at a time, matching DhanHQ::Strategy::Base's
17
+ # single `@position` / entry-then-exit model — no pyramiding, no shorting.
18
+ class Runner
19
+ DEFAULT_QUANTITY = ->(equity, price) { price.positive? ? (equity / price).floor : 0 }
20
+ NO_FEES = ->(_trade_value) { 0.0 }
21
+
22
+ # @param strategy [DhanHQ::Strategy::Base]
23
+ # @param data [DhanHQ::MarketData::OHLCSeries]
24
+ # @param initial_capital [Float]
25
+ # @param quantity [#call, Integer] `->(equity, price) { ... }`, or a fixed integer
26
+ # @param fees [#call] `->(trade_value) { ... }`, charged once per leg (entry, exit)
27
+ # @param max_bars_held [Integer, nil] force-exit a position held this many bars or longer
28
+ def initialize(strategy:, data:, initial_capital:, quantity: DEFAULT_QUANTITY, fees: NO_FEES, max_bars_held: nil)
29
+ @strategy = strategy
30
+ @candles = data.respond_to?(:candles) ? data.candles : Array(data)
31
+ @initial_capital = initial_capital.to_f
32
+ @quantity = quantity.respond_to?(:call) ? quantity : ->(_equity, _price) { quantity }
33
+ @fees = fees
34
+ @max_bars_held = max_bars_held
35
+ end
36
+
37
+ # @return [DhanHQ::Backtest::Result]
38
+ def run
39
+ return Result.new(trades: [], equity_curve: [], initial_capital: @initial_capital) if @candles.empty?
40
+
41
+ equity = @initial_capital
42
+ equity_curve = []
43
+ trades = []
44
+ window_candles = []
45
+ open_trade = nil
46
+ entry_index = nil
47
+
48
+ @candles.each_with_index do |candle, index|
49
+ window_candles << candle
50
+ window = DhanHQ::MarketData::OHLCSeries.new(window_candles)
51
+ has_next = index < @candles.size - 1
52
+
53
+ if open_trade && has_next
54
+ trade = maybe_exit(open_trade, window, index, entry_index, equity, equity_curve, @candles[index + 1])
55
+
56
+ if trade
57
+ trades << trade
58
+ equity += trade.pnl
59
+ open_trade = nil
60
+ entry_index = nil
61
+ end
62
+ elsif open_trade.nil? && has_next
63
+ open_trade, entry_index = maybe_enter(window, @candles[index + 1], equity, index)
64
+ end
65
+
66
+ # A position opened this very iteration (via maybe_enter) isn't filled until the
67
+ # *next* candle — mark-to-market must stay flat until index reaches entry_index,
68
+ # or the equity curve would price the position off a fill that hasn't happened yet.
69
+ filled = open_trade && index >= entry_index
70
+ equity_curve << mark_to_market(equity, filled ? open_trade : nil, candle.close)
71
+ end
72
+
73
+ if open_trade
74
+ last = @candles.last
75
+ trade = close_trade(open_trade, last.timestamp, last.close, :end_of_data)
76
+ trades << trade
77
+ equity += trade.pnl
78
+ equity_curve[-1] = equity
79
+ end
80
+
81
+ Result.new(trades: trades, equity_curve: equity_curve, initial_capital: @initial_capital)
82
+ end
83
+
84
+ private
85
+
86
+ def maybe_enter(window, next_candle, equity, index)
87
+ signal = @strategy.evaluate_entry(window)
88
+ return [nil, nil] unless signal.buy?
89
+
90
+ qty = @quantity.call(equity, next_candle.open)
91
+ return [nil, nil] unless qty.positive?
92
+
93
+ [{ entry_time: next_candle.timestamp, entry_price: next_candle.open, quantity: qty }, index + 1]
94
+ end
95
+
96
+ def maybe_exit(open_trade, window, index, entry_index, equity, equity_curve, next_candle)
97
+ bars_held = index - entry_index
98
+ forced = @max_bars_held && bars_held >= @max_bars_held
99
+ drawdown = drawdown_pct(equity_curve, equity)
100
+ violations = @strategy.check_risks(equity: equity, position: open_trade, drawdown: drawdown)
101
+ signal = @strategy.evaluate_exit(window)
102
+
103
+ return unless forced || violations.any? || signal.sell?
104
+
105
+ reason = if forced
106
+ :max_bars_held
107
+ elsif violations.any?
108
+ :risk_violation
109
+ else
110
+ :signal
111
+ end
112
+ close_trade(open_trade, next_candle.timestamp, next_candle.open, reason)
113
+ end
114
+
115
+ def close_trade(open_trade, exit_time, exit_price, reason)
116
+ entry_value = open_trade[:entry_price] * open_trade[:quantity]
117
+ exit_value = exit_price * open_trade[:quantity]
118
+
119
+ Trade.new(
120
+ entry_time: open_trade[:entry_time],
121
+ entry_price: open_trade[:entry_price],
122
+ exit_time: exit_time,
123
+ exit_price: exit_price,
124
+ quantity: open_trade[:quantity],
125
+ exit_reason: reason,
126
+ fees: @fees.call(entry_value) + @fees.call(exit_value)
127
+ )
128
+ end
129
+
130
+ def mark_to_market(equity, open_trade, close_price)
131
+ return equity unless open_trade
132
+
133
+ equity + ((close_price - open_trade[:entry_price]) * open_trade[:quantity])
134
+ end
135
+
136
+ def drawdown_pct(equity_curve, current_equity)
137
+ peak = (equity_curve + [current_equity]).max
138
+ return 0.0 if peak.nil? || peak.zero?
139
+
140
+ [((peak - current_equity) / peak) * 100, 0.0].max
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Backtest
5
+ # A single completed long round-trip trade produced by Runner.
6
+ #
7
+ # `fees` is the total cost charged across both legs (entry + exit), already
8
+ # netted into `pnl` — it is not deducted again by callers.
9
+ Trade = Struct.new(
10
+ :entry_time, :entry_price, :exit_time, :exit_price, :quantity, :exit_reason, :fees
11
+ ) do
12
+ # Net profit/loss for this trade, after fees.
13
+ #
14
+ # @return [Float]
15
+ def pnl
16
+ ((exit_price - entry_price) * quantity) - fees.to_f
17
+ end
18
+
19
+ # Net profit/loss as a percentage of the entry value.
20
+ #
21
+ # @return [Float]
22
+ def pnl_pct
23
+ entry_value = entry_price.to_f * quantity.to_f
24
+ return 0.0 if entry_value.zero?
25
+
26
+ (pnl / entry_value) * 100
27
+ end
28
+
29
+ # @return [Boolean] whether this trade closed profitably after fees
30
+ def win?
31
+ pnl.positive?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Backtesting engine that replays a DhanHQ::Strategy::Base against historical
5
+ # OHLC data and reports trades, an equity curve, and summary performance stats.
6
+ #
7
+ # @example Backtest a strategy against daily candles
8
+ # data = DhanHQ::Models::HistoricalData.daily(
9
+ # security_id: "1333", exchange_segment: "NSE_EQ",
10
+ # instrument: "EQUITY", from_date: "2024-01-01", to_date: "2024-12-31"
11
+ # )
12
+ # series = DhanHQ::MarketData::OHLCSeries.from_response(data)
13
+ #
14
+ # result = DhanHQ::Backtest::Runner.new(
15
+ # strategy: MyStrategy.new,
16
+ # data: series,
17
+ # initial_capital: 100_000.0
18
+ # ).run
19
+ #
20
+ # result.summary #=> { total_return_pct: 12.4, num_trades: 8, win_rate: 62.5, ... }
21
+ #
22
+ module Backtest
23
+ end
24
+ end
@@ -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}.
@@ -119,6 +132,15 @@ module DhanHQ
119
132
  # @return [Integer]
120
133
  attr_accessor :market_depth_level
121
134
 
135
+ # When true, every raw inbound WebSocket frame (market feed, order updates,
136
+ # market depth) is logged as a hex dump at debug level before it's parsed.
137
+ # Off by default -- high volume, and the check happens before any hex
138
+ # encoding work so there's no cost when disabled.
139
+ #
140
+ # Set via +DHAN_WS_DEBUG=true+ or in {DhanHQ.configure}.
141
+ # @return [Boolean]
142
+ attr_accessor :ws_debug
143
+
122
144
  # Setters for websocket URLs
123
145
  attr_writer :ws_order_url, :ws_market_feed_url, :ws_market_depth_url
124
146
 
@@ -175,11 +197,21 @@ module DhanHQ
175
197
  @retry_non_idempotent_writes == true
176
198
  end
177
199
 
200
+ # @return [Boolean] True when ambiguous write failures should be reported.
201
+ def warn_on_ambiguous_write_failure?
202
+ @warn_on_ambiguous_write_failure == true
203
+ end
204
+
178
205
  # @return [Boolean] True when a correlation id should be generated for orders.
179
206
  def auto_correlation_id?
180
207
  @auto_correlation_id == true
181
208
  end
182
209
 
210
+ # @return [Boolean] True when raw WebSocket frames should be hex-logged.
211
+ def ws_debug?
212
+ @ws_debug == true
213
+ end
214
+
183
215
  # Initializes a new configuration instance with default values.
184
216
  #
185
217
  # @example
@@ -193,12 +225,14 @@ module DhanHQ
193
225
  @dry_run = env_flag("DHAN_DRY_RUN", default: false)
194
226
  @retry_non_idempotent_writes = env_flag("DHAN_RETRY_WRITES", default: false)
195
227
  @auto_correlation_id = env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
228
+ @warn_on_ambiguous_write_failure = env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE", default: true)
196
229
  @base_url = ENV.fetch("DHAN_BASE_URL", nil)
197
230
  @ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
198
231
  @ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
199
232
  @ws_market_feed_url = ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
200
233
  @ws_market_depth_url = ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
201
234
  @market_depth_level = ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20").to_i
235
+ @ws_debug = env_flag("DHAN_WS_DEBUG", default: false)
202
236
  @ws_user_type = ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
203
237
  @partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
204
238
  @partner_secret = ENV.fetch("DHAN_PARTNER_SECRET", 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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Jobs
5
+ # Places a Dhan order via ActiveJob, without ever letting a queue adapter
6
+ # (Sidekiq, Resque, ...) retry it.
7
+ #
8
+ # DhanHQ order writes are not idempotent -- a timed-out POST /v2/orders may
9
+ # already have reached the exchange, so retrying it can place a duplicate
10
+ # order (see the README's "Order Retries and Duplicate Protection"). Most
11
+ # adapters retry unhandled exceptions at the backend level by default
12
+ # (Sidekiq's own retry, independent of ActiveJob's opt-in `retry_on`), so
13
+ # the only adapter-agnostic way to guarantee this write is never silently
14
+ # retried is to make sure the exception never reaches the adapter at all.
15
+ # `discard_on` does exactly that: it's handled inside ActiveJob's own
16
+ # `execute`, so from the adapter's point of view the job completed, not
17
+ # failed -- there is nothing left for the adapter's own retry logic to
18
+ # act on.
19
+ #
20
+ # @example
21
+ # DhanHQ::Jobs::PlaceOrderJob.perform_later(
22
+ # transaction_type: DhanHQ::Constants::TransactionType::BUY,
23
+ # exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
24
+ # product_type: DhanHQ::Constants::ProductType::INTRADAY,
25
+ # order_type: DhanHQ::Constants::OrderType::LIMIT,
26
+ # validity: DhanHQ::Constants::Validity::DAY,
27
+ # security_id: "11536",
28
+ # quantity: 5,
29
+ # price: 1500.0
30
+ # )
31
+ class PlaceOrderJob < ActiveJob::Base
32
+ discard_on DhanHQ::OrderError do |_job, error|
33
+ DhanHQ.logger&.error("[DhanHQ::Jobs::PlaceOrderJob] order rejected: #{error.message}")
34
+ end
35
+
36
+ discard_on DhanHQ::RiskViolation do |_job, error|
37
+ DhanHQ.logger&.warn("[DhanHQ::Jobs::PlaceOrderJob] risk check blocked the order: #{error.message}")
38
+ end
39
+
40
+ # @param params [Hash] Same params accepted by DhanHQ::Models::Order.place.
41
+ # @return [DhanHQ::Models::Order]
42
+ def perform(params)
43
+ DhanHQ::Models::Order.place!(params)
44
+ end
45
+ end
46
+ end
47
+ 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"
@@ -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
  ##