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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/GUIDE.md +16 -0
- data/README.md +93 -18
- data/docs/CONFIGURATION.md +39 -14
- data/docs/RAILS_INTEGRATION.md +60 -17
- data/docs/RELEASE_GUIDE.md +1 -1
- data/docs/TROUBLESHOOTING.md +15 -1
- data/lib/DhanHQ/backtest/result.rb +87 -0
- data/lib/DhanHQ/backtest/runner.rb +144 -0
- data/lib/DhanHQ/backtest/trade.rb +35 -0
- data/lib/DhanHQ/backtest.rb +24 -0
- data/lib/DhanHQ/concerns/bang_writes.rb +69 -0
- data/lib/DhanHQ/concerns/tracked_writes.rb +56 -0
- data/lib/DhanHQ/configuration.rb +35 -1
- data/lib/DhanHQ/core/base_model.rb +6 -13
- data/lib/DhanHQ/deprecation.rb +62 -0
- data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
- data/lib/DhanHQ/models/alert_order.rb +7 -0
- data/lib/DhanHQ/models/forever_order.rb +9 -0
- data/lib/DhanHQ/models/global_stocks/order.rb +9 -0
- data/lib/DhanHQ/models/iceberg_order.rb +9 -0
- data/lib/DhanHQ/models/multi_order.rb +7 -0
- data/lib/DhanHQ/models/order.rb +28 -0
- data/lib/DhanHQ/models/pnl_exit.rb +7 -0
- data/lib/DhanHQ/models/super_order.rb +9 -0
- data/lib/DhanHQ/models/twap_order.rb +9 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/write_result.rb +163 -0
- data/lib/DhanHQ/ws/base_connection.rb +1 -0
- data/lib/DhanHQ/ws/connection.rb +1 -0
- data/lib/DhanHQ/ws/orders/connection.rb +1 -0
- data/lib/DhanHQ/ws.rb +25 -0
- data/lib/dhan_hq.rb +5 -0
- data/lib/generators/dhanhq/install/install_generator.rb +45 -0
- data/lib/generators/dhanhq/install/templates/initializer.rb +18 -0
- data/lib/generators/dhanhq/install/templates/market_feed_channel.rb +7 -0
- data/lib/generators/dhanhq/install/templates/market_feed_worker.rb +27 -0
- data/lib/generators/dhanhq/install/templates/place_order_service.rb +24 -0
- data/skills/dhanhq-ruby/references/portfolio.md +15 -8
- 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
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
data/lib/DhanHQ/models/order.rb
CHANGED
|
@@ -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
|
##
|