DhanHQ 3.3.0 → 4.0.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 +49 -0
- data/GUIDE.md +16 -0
- data/README.md +67 -17
- data/docs/CONFIGURATION.md +26 -0
- data/docs/RAILS_INTEGRATION.md +60 -17
- 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/client.rb +76 -26
- data/lib/DhanHQ/configuration.rb +40 -23
- data/lib/DhanHQ/core/base_api.rb +3 -2
- data/lib/DhanHQ/dry_run/simulator.rb +3 -2
- data/lib/DhanHQ/helpers/request_helper.rb +13 -3
- data/lib/DhanHQ/jobs/place_order_job.rb +47 -0
- data/lib/DhanHQ/resources/market_feed.rb +1 -1
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/base_connection.rb +3 -1
- data/lib/DhanHQ/ws/client.rb +7 -5
- data/lib/DhanHQ/ws/connection.rb +1 -0
- data/lib/DhanHQ/ws/market_depth/client.rb +4 -3
- data/lib/DhanHQ/ws/orders/client.rb +4 -4
- data/lib/DhanHQ/ws/orders/connection.rb +2 -1
- data/lib/DhanHQ/ws.rb +25 -0
- data/lib/dhan_hq.rb +74 -46
- 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
- metadata +44 -31
|
@@ -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
|
data/lib/DhanHQ/client.rb
CHANGED
|
@@ -42,42 +42,68 @@ module DhanHQ
|
|
|
42
42
|
].freeze
|
|
43
43
|
private_constant :RETRYABLE_TRANSPORT_ERRORS
|
|
44
44
|
|
|
45
|
-
attr_reader :token_manager
|
|
45
|
+
attr_reader :config, :token_manager, :api_type
|
|
46
46
|
|
|
47
|
-
# Initializes a new DhanHQ Client.
|
|
48
|
-
#
|
|
49
|
-
# Establishes state and checks its one invariant; the HTTP connection is opened
|
|
50
|
-
# lazily on first use, so constructing a client does no network setup.
|
|
51
|
-
#
|
|
52
|
-
# @example Create a new client:
|
|
53
|
-
# client = DhanHQ::Client.new(api_type: :order_api)
|
|
47
|
+
# Initializes a new DhanHQ Client with instance-level configuration and rate limiting.
|
|
54
48
|
#
|
|
49
|
+
# @param config_or_attrs [DhanHQ::Configuration, Hash, nil] Optional configuration or attribute hash
|
|
50
|
+
# @param config [DhanHQ::Configuration, nil] Optional keyword configuration
|
|
55
51
|
# @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
|
|
56
|
-
# @
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Shared per API type, so separate clients coordinate against one limit.
|
|
52
|
+
# @param attrs [Hash] Additional keyword configuration attributes
|
|
53
|
+
def initialize(config_or_attrs = nil, config: nil, api_type: :order_api, **attrs)
|
|
54
|
+
@config = resolve_config(config_or_attrs, config, attrs)
|
|
55
|
+
@api_type = api_type
|
|
61
56
|
@rate_limiter = RateLimiter.for(api_type)
|
|
62
57
|
|
|
63
58
|
raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
|
|
64
59
|
end
|
|
65
60
|
|
|
66
61
|
# The Faraday connection used for HTTP requests.
|
|
67
|
-
#
|
|
68
|
-
# Built on first use and rebuilt whenever the configured base URL changes — which
|
|
69
|
-
# happens when sandbox mode is toggled, or when a token endpoint hands back a
|
|
70
|
-
# different host mid-process.
|
|
62
|
+
# Built on first use and rebuilt whenever the configured base URL changes.
|
|
71
63
|
#
|
|
72
64
|
# @return [Faraday::Connection]
|
|
73
65
|
def connection
|
|
74
|
-
current_url =
|
|
66
|
+
current_url = @config.base_url
|
|
75
67
|
return @connection if @connection && @last_base_url == current_url
|
|
76
68
|
|
|
77
69
|
@last_base_url = current_url
|
|
78
70
|
@connection = build_connection(current_url)
|
|
79
71
|
end
|
|
80
72
|
|
|
73
|
+
# Resource accessors bound to this client instance. Each resource gets the client
|
|
74
|
+
# matching its own API_TYPE, so e.g. option chain calls throttle at 1 per 3 sec
|
|
75
|
+
# instead of borrowing the order client's 10/sec limiter.
|
|
76
|
+
def orders = @orders ||= Resources::Orders.new(client: client_for(Resources::Orders::API_TYPE))
|
|
77
|
+
def market_feed = @market_feed ||= Resources::MarketFeed.new(client: client_for(Resources::MarketFeed::API_TYPE))
|
|
78
|
+
def positions = @positions ||= Resources::Positions.new(client: client_for(Resources::Positions::API_TYPE))
|
|
79
|
+
def holdings = @holdings ||= Resources::Holdings.new(client: client_for(Resources::Holdings::API_TYPE))
|
|
80
|
+
def historical_data = @historical_data ||= Resources::HistoricalData.new(client: client_for(Resources::HistoricalData::API_TYPE))
|
|
81
|
+
def option_chain = @option_chain ||= Resources::OptionChain.new(client: client_for(Resources::OptionChain::API_TYPE))
|
|
82
|
+
def funds = @funds ||= Resources::Funds.new(client: client_for(Resources::Funds::API_TYPE))
|
|
83
|
+
def trades = @trades ||= Resources::Trades.new(client: client_for(Resources::Trades::API_TYPE))
|
|
84
|
+
def forever_orders = @forever_orders ||= Resources::ForeverOrders.new(client: client_for(Resources::ForeverOrders::API_TYPE))
|
|
85
|
+
def alert_orders = @alert_orders ||= Resources::AlertOrders.new(client: client_for(Resources::AlertOrders::API_TYPE))
|
|
86
|
+
def iceberg_orders = @iceberg_orders ||= Resources::IcebergOrders.new(client: client_for(Resources::IcebergOrders::API_TYPE))
|
|
87
|
+
def super_orders = @super_orders ||= Resources::SuperOrders.new(client: client_for(Resources::SuperOrders::API_TYPE))
|
|
88
|
+
def twap_orders = @twap_orders ||= Resources::TwapOrders.new(client: client_for(Resources::TwapOrders::API_TYPE))
|
|
89
|
+
def statements = @statements ||= Resources::Statements.new(client: client_for(Resources::Statements::API_TYPE))
|
|
90
|
+
def kill_switch = @kill_switch ||= Resources::KillSwitch.new(client: client_for(Resources::KillSwitch::API_TYPE))
|
|
91
|
+
def pnl_exit = @pnl_exit ||= Resources::PnlExit.new(client: client_for(Resources::PnlExit::API_TYPE))
|
|
92
|
+
def trader_control = @trader_control ||= Resources::TraderControl.new(client: client_for(Resources::TraderControl::API_TYPE))
|
|
93
|
+
def ip_setup = @ip_setup ||= Resources::IPSetup.new(client: client_for(Resources::IPSetup::API_TYPE))
|
|
94
|
+
def edis = @edis ||= Resources::Edis.new(client: client_for(Resources::Edis::API_TYPE))
|
|
95
|
+
|
|
96
|
+
# WebSocket streams bound to this client instance configuration
|
|
97
|
+
def ws_market_feed(mode: :ticker, url: nil, &block)
|
|
98
|
+
WS::Client.new(mode: mode, url: url, config: @config).tap do |ws|
|
|
99
|
+
ws.start if block_given?
|
|
100
|
+
ws.on(:tick, &block) if block_given?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ws_order_update = WS::Orders::Client.new(config: @config)
|
|
105
|
+
def ws_market_depth = WS::MarketDepth::Client.new(config: @config)
|
|
106
|
+
|
|
81
107
|
# Sends an HTTP request to the API with automatic retry for transient errors.
|
|
82
108
|
#
|
|
83
109
|
# @param method [Symbol] The HTTP method (`:get`, `:post`, `:put`, `:delete`)
|
|
@@ -93,8 +119,6 @@ module DhanHQ
|
|
|
93
119
|
@token_manager&.ensure_valid_token!
|
|
94
120
|
@rate_limiter.throttle!
|
|
95
121
|
|
|
96
|
-
# A non-idempotent write is not safely retryable: the API has no idempotency
|
|
97
|
-
# key, so a request that timed out may already have reached the exchange.
|
|
98
122
|
effective_retries = retryable_write?(method, path) ? retries : 0
|
|
99
123
|
|
|
100
124
|
with_auth_retry do
|
|
@@ -112,10 +136,9 @@ module DhanHQ
|
|
|
112
136
|
yield
|
|
113
137
|
rescue DhanHQ::InvalidAuthenticationError, DhanHQ::InvalidTokenError,
|
|
114
138
|
DhanHQ::TokenExpiredError, DhanHQ::AuthenticationFailedError => e
|
|
115
|
-
|
|
116
|
-
raise unless config&.access_token_provider
|
|
139
|
+
raise unless @config&.access_token_provider
|
|
117
140
|
|
|
118
|
-
config.on_token_expired&.call(e)
|
|
141
|
+
@config.on_token_expired&.call(e)
|
|
119
142
|
DhanHQ.logger&.warn("[DhanHQ::Client] Auth failure (#{e.class}), retrying once with fresh token")
|
|
120
143
|
yield
|
|
121
144
|
end
|
|
@@ -218,20 +241,47 @@ module DhanHQ
|
|
|
218
241
|
@token_manager.generate!
|
|
219
242
|
end
|
|
220
243
|
|
|
244
|
+
# A client sharing this one's config but rate-limited for a different API tier.
|
|
245
|
+
# RateLimiter.for(api_type) is a shared instance per type, so sibling clients still
|
|
246
|
+
# throttle against the same counters as any other client using that tier.
|
|
247
|
+
#
|
|
248
|
+
# @param api_type [Symbol] Target API tier (:order_api, :data_api, :non_trading_api, :option_chain)
|
|
249
|
+
# @return [DhanHQ::Client]
|
|
250
|
+
def client_for(api_type)
|
|
251
|
+
return self if api_type == @api_type
|
|
252
|
+
|
|
253
|
+
@sibling_clients ||= {}
|
|
254
|
+
@sibling_clients[api_type] ||= self.class.new(config: @config, api_type: api_type)
|
|
255
|
+
end
|
|
256
|
+
|
|
221
257
|
private
|
|
222
258
|
|
|
259
|
+
def resolve_config(config_or_attrs, keyword_config, attrs = {})
|
|
260
|
+
if config_or_attrs.is_a?(DhanHQ::Configuration)
|
|
261
|
+
config_or_attrs
|
|
262
|
+
elsif config_or_attrs.is_a?(Hash)
|
|
263
|
+
DhanHQ::Configuration.new(config_or_attrs.merge(attrs))
|
|
264
|
+
elsif keyword_config
|
|
265
|
+
keyword_config
|
|
266
|
+
elsif attrs.any?
|
|
267
|
+
DhanHQ::Configuration.new(attrs)
|
|
268
|
+
else
|
|
269
|
+
DhanHQ.ensure_configuration!
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
223
273
|
# Simulator that answers state-changing requests locally when dry run is on.
|
|
224
274
|
#
|
|
225
275
|
# @return [DhanHQ::DryRun::Simulator]
|
|
226
276
|
def dry_run
|
|
227
|
-
@dry_run ||= DhanHQ::DryRun::Simulator.new
|
|
277
|
+
@dry_run ||= DhanHQ::DryRun::Simulator.new(config: @config)
|
|
228
278
|
end
|
|
229
279
|
|
|
230
280
|
# A write may be auto-retried only when the user has explicitly opted in.
|
|
231
281
|
def retryable_write?(method, path)
|
|
232
282
|
return true unless WritePaths.mutating?(method, path)
|
|
233
283
|
|
|
234
|
-
|
|
284
|
+
@config&.retry_non_idempotent_writes? || false
|
|
235
285
|
end
|
|
236
286
|
|
|
237
287
|
# Fills in a +correlationId+ on order placements that lack one, so a caller who
|
|
@@ -240,7 +290,7 @@ module DhanHQ
|
|
|
240
290
|
def with_correlation_id(method, path, payload)
|
|
241
291
|
return payload unless WritePaths.order_placement?(method, path)
|
|
242
292
|
return payload unless payload.is_a?(Hash)
|
|
243
|
-
return payload unless
|
|
293
|
+
return payload unless @config&.auto_correlation_id?
|
|
244
294
|
return payload if correlation_id?(payload)
|
|
245
295
|
|
|
246
296
|
inject_correlation_id(payload)
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -132,6 +132,15 @@ module DhanHQ
|
|
|
132
132
|
# @return [Integer]
|
|
133
133
|
attr_accessor :market_depth_level
|
|
134
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
|
+
|
|
135
144
|
# Setters for websocket URLs
|
|
136
145
|
attr_writer :ws_order_url, :ws_market_feed_url, :ws_market_depth_url
|
|
137
146
|
|
|
@@ -198,29 +207,37 @@ module DhanHQ
|
|
|
198
207
|
@auto_correlation_id == true
|
|
199
208
|
end
|
|
200
209
|
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
@
|
|
209
|
-
@
|
|
210
|
-
@
|
|
211
|
-
@
|
|
212
|
-
@
|
|
213
|
-
@
|
|
214
|
-
@
|
|
215
|
-
@
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
@
|
|
222
|
-
@
|
|
223
|
-
@
|
|
210
|
+
# @return [Boolean] True when raw WebSocket frames should be hex-logged.
|
|
211
|
+
def ws_debug?
|
|
212
|
+
@ws_debug == true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def initialize(attrs = {})
|
|
216
|
+
@client_id = attrs[:client_id] || ENV.fetch("DHAN_CLIENT_ID", nil)
|
|
217
|
+
@access_token = attrs[:access_token] || ENV.fetch("DHAN_ACCESS_TOKEN", nil)
|
|
218
|
+
@access_token_provider = attrs[:access_token_provider]
|
|
219
|
+
@on_token_expired = attrs[:on_token_expired]
|
|
220
|
+
@sandbox = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
|
|
221
|
+
@dry_run = attrs.key?(:dry_run) ? attrs[:dry_run] : env_flag("DHAN_DRY_RUN", default: false)
|
|
222
|
+
@retry_non_idempotent_writes = attrs.key?(:retry_non_idempotent_writes) ? attrs[:retry_non_idempotent_writes] : env_flag("DHAN_RETRY_WRITES", default: false)
|
|
223
|
+
@auto_correlation_id = attrs.key?(:auto_correlation_id) ? attrs[:auto_correlation_id] : env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
|
|
224
|
+
@warn_on_ambiguous_write_failure = if attrs.key?(:warn_on_ambiguous_write_failure)
|
|
225
|
+
attrs[:warn_on_ambiguous_write_failure]
|
|
226
|
+
else
|
|
227
|
+
env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE",
|
|
228
|
+
default: true)
|
|
229
|
+
end
|
|
230
|
+
@base_url = attrs[:base_url] || ENV.fetch("DHAN_BASE_URL", nil)
|
|
231
|
+
@ws_version = (attrs[:ws_version] || ENV.fetch("DHAN_WS_VERSION", 2)).to_i
|
|
232
|
+
@ws_order_url = attrs[:ws_order_url] || ENV.fetch("DHAN_WS_ORDER_URL", nil)
|
|
233
|
+
@ws_market_feed_url = attrs[:ws_market_feed_url] || ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
|
|
234
|
+
@ws_market_depth_url = attrs[:ws_market_depth_url] || ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
|
|
235
|
+
@market_depth_level = (attrs[:market_depth_level] || ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20")).to_i
|
|
236
|
+
@ws_debug = attrs.key?(:ws_debug) ? attrs[:ws_debug] : env_flag("DHAN_WS_DEBUG", default: false)
|
|
237
|
+
@ws_user_type = attrs[:ws_user_type] || ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
|
|
238
|
+
@partner_id = attrs[:partner_id] || ENV.fetch("DHAN_PARTNER_ID", nil)
|
|
239
|
+
@partner_secret = attrs[:partner_secret] || ENV.fetch("DHAN_PARTNER_SECRET", nil)
|
|
240
|
+
yield(self) if block_given?
|
|
224
241
|
end
|
|
225
242
|
|
|
226
243
|
private
|
data/lib/DhanHQ/core/base_api.rb
CHANGED
|
@@ -17,9 +17,10 @@ module DhanHQ
|
|
|
17
17
|
|
|
18
18
|
# Initializes the BaseAPI with the appropriate Client instance
|
|
19
19
|
#
|
|
20
|
+
# @param client [DhanHQ::Client, nil] Optional injected client instance
|
|
20
21
|
# @param api_type [Symbol] API type (`:order_api`, `:data_api`, `:non_trading_api`)
|
|
21
|
-
def initialize(api_type: self.class::API_TYPE)
|
|
22
|
-
@client = DhanHQ::Client.new(api_type: api_type)
|
|
22
|
+
def initialize(client: nil, api_type: self.class::API_TYPE)
|
|
23
|
+
@client = client || DhanHQ::Client.new(api_type: api_type)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Perform a GET request via `Client`
|
|
@@ -25,8 +25,9 @@ module DhanHQ
|
|
|
25
25
|
# Extracts a fabricated id out of a request path.
|
|
26
26
|
ID_PATTERN = /#{Regexp.escape(ID_PREFIX)}[A-Za-z0-9]+/
|
|
27
27
|
|
|
28
|
-
def initialize(ledger: Ledger.instance)
|
|
28
|
+
def initialize(ledger: Ledger.instance, config: nil)
|
|
29
29
|
@ledger = ledger
|
|
30
|
+
@config = config
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
# Whether this request should be answered locally.
|
|
@@ -35,7 +36,7 @@ module DhanHQ
|
|
|
35
36
|
# @param path [String, nil] Request path
|
|
36
37
|
# @return [Boolean]
|
|
37
38
|
def simulates?(method, path)
|
|
38
|
-
return false unless DhanHQ.configuration&.dry_run?
|
|
39
|
+
return false unless (@config || DhanHQ.configuration)&.dry_run?
|
|
39
40
|
|
|
40
41
|
WritePaths.mutating?(method, path) || replayable_read?(method, path)
|
|
41
42
|
end
|
|
@@ -21,6 +21,16 @@ module DhanHQ
|
|
|
21
21
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
|
+
def current_config
|
|
25
|
+
if respond_to?(:config) && config
|
|
26
|
+
config
|
|
27
|
+
elsif respond_to?(:client) && client.respond_to?(:config) && client.config
|
|
28
|
+
client.config
|
|
29
|
+
else
|
|
30
|
+
DhanHQ.configuration
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
# Dynamically builds headers for each request.
|
|
25
35
|
#
|
|
26
36
|
# @param path [String] The API endpoint path.
|
|
@@ -41,7 +51,7 @@ module DhanHQ
|
|
|
41
51
|
|
|
42
52
|
# Add client-id for DATA APIs (now including sandbox profile/funds)
|
|
43
53
|
if data_api?(path)
|
|
44
|
-
client_id =
|
|
54
|
+
client_id = current_config&.client_id
|
|
45
55
|
unless client_id
|
|
46
56
|
raise DhanHQ::InvalidAuthenticationError,
|
|
47
57
|
"client_id is required for DATA APIs but not set. Please configure DhanHQ with DHAN_CLIENT_ID."
|
|
@@ -53,7 +63,7 @@ module DhanHQ
|
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
def resolved_access_token
|
|
56
|
-
|
|
66
|
+
current_config&.resolved_access_token
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
# Determines if the API path requires a `client-id` header.
|
|
@@ -80,7 +90,7 @@ module DhanHQ
|
|
|
80
90
|
|
|
81
91
|
out = payload
|
|
82
92
|
if path && %i[post put patch].include?(method)
|
|
83
|
-
client_id =
|
|
93
|
+
client_id = current_config&.client_id
|
|
84
94
|
needs_client_id = data_api?(path) || payload_requires_dhan_client_id?(path)
|
|
85
95
|
if client_id && needs_client_id && !payload.key?(:dhanClientId) && !payload.key?("dhanClientId")
|
|
86
96
|
out = payload.dup
|
|
@@ -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
|
data/lib/DhanHQ/version.rb
CHANGED
|
@@ -23,8 +23,9 @@ module DhanHQ
|
|
|
23
23
|
# @param options [Hash] Connection options
|
|
24
24
|
# @option options [Integer] :max_backoff Maximum backoff time (default: 90)
|
|
25
25
|
# @option options [Integer] :cool_off_429 Cool off time for 429 errors (default: 60)
|
|
26
|
-
def initialize(url:, **options)
|
|
26
|
+
def initialize(url:, config: nil, **options)
|
|
27
27
|
@url = url
|
|
28
|
+
@config = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
|
|
28
29
|
@callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
|
|
29
30
|
@started = Concurrent::AtomicBoolean.new(false)
|
|
30
31
|
@stop = false
|
|
@@ -212,6 +213,7 @@ module DhanHQ
|
|
|
212
213
|
# Handle WebSocket message event
|
|
213
214
|
# @param ev [Event] WebSocket message event
|
|
214
215
|
def handle_message(ev)
|
|
216
|
+
WS.debug_frame(self.class.name, ev.data)
|
|
215
217
|
emit(:raw, ev.data)
|
|
216
218
|
process_message(ev.data) if respond_to?(:process_message, true)
|
|
217
219
|
end
|
data/lib/DhanHQ/ws/client.rb
CHANGED
|
@@ -20,7 +20,8 @@ module DhanHQ
|
|
|
20
20
|
class Client
|
|
21
21
|
# @param mode [Symbol] Feed mode (:ticker, :quote, :full).
|
|
22
22
|
# @param url [String, nil] Optional custom WebSocket endpoint.
|
|
23
|
-
|
|
23
|
+
# @param config [DhanHQ::Configuration, nil] Optional configuration instance.
|
|
24
|
+
def initialize(mode: :ticker, url: nil, config: nil)
|
|
24
25
|
@mode = mode # :ticker, :quote, :full (adjust to your API)
|
|
25
26
|
@bus = CmdBus.new
|
|
26
27
|
@state = SubState.new
|
|
@@ -29,13 +30,14 @@ module DhanHQ
|
|
|
29
30
|
@last_message_at = Concurrent::AtomicReference.new(nil)
|
|
30
31
|
@connected_at = Concurrent::AtomicReference.new(nil)
|
|
31
32
|
@reconnects = Concurrent::AtomicFixnum.new(0)
|
|
33
|
+
@config = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
|
|
32
34
|
|
|
33
|
-
token =
|
|
35
|
+
token = @config.resolved_access_token
|
|
34
36
|
raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
|
|
35
37
|
|
|
36
|
-
cid =
|
|
37
|
-
ver = (
|
|
38
|
-
base = url ||
|
|
38
|
+
cid = @config.client_id or raise "DhanHQ.client_id not set"
|
|
39
|
+
ver = (@config.respond_to?(:ws_version) && @config.ws_version) || 2
|
|
40
|
+
base = url || @config.ws_market_feed_url
|
|
39
41
|
@url = base.include?("?") ? base : "#{base}?version=#{ver}&token=#{token}&clientId=#{cid}&authType=2"
|
|
40
42
|
end
|
|
41
43
|
|
data/lib/DhanHQ/ws/connection.rb
CHANGED
|
@@ -19,11 +19,12 @@ module DhanHQ
|
|
|
19
19
|
##
|
|
20
20
|
# Initialize Market Depth WebSocket client
|
|
21
21
|
# @param symbols [Array<String, Hash>] List of symbols (or metadata hashes) to subscribe to
|
|
22
|
+
# @param config [DhanHQ::Configuration, nil] Optional configuration instance
|
|
22
23
|
# @param options [Hash] Connection options
|
|
23
|
-
def initialize(symbols: [], **options)
|
|
24
|
-
cfg = DhanHQ.configuration
|
|
24
|
+
def initialize(symbols: [], config: nil, **options)
|
|
25
|
+
cfg = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
|
|
25
26
|
url = options[:url] || build_market_depth_url(cfg)
|
|
26
|
-
super(url: url, **options)
|
|
27
|
+
super(url: url, config: cfg, **options)
|
|
27
28
|
|
|
28
29
|
@symbols = Array(symbols)
|
|
29
30
|
@subscriptions = Concurrent::Map.new
|
|
@@ -16,15 +16,15 @@ module DhanHQ
|
|
|
16
16
|
# Maximum age of orders in tracker in seconds (default: 7 days)
|
|
17
17
|
MAX_ORDER_AGE = ENV.fetch("DHAN_WS_MAX_ORDER_AGE", 604_800).to_i
|
|
18
18
|
|
|
19
|
-
def initialize(url: nil, **options)
|
|
19
|
+
def initialize(url: nil, config: nil, **options)
|
|
20
20
|
@callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
|
|
21
21
|
@started = Concurrent::AtomicBoolean.new(false)
|
|
22
22
|
@order_tracker = Concurrent::Map.new
|
|
23
23
|
@order_timestamps = Concurrent::Map.new
|
|
24
24
|
@cleanup_mutex = Mutex.new
|
|
25
25
|
@cleanup_thread = nil
|
|
26
|
-
|
|
27
|
-
@url = url ||
|
|
26
|
+
@config = config || DhanHQ.configuration || DhanHQ.ensure_configuration!
|
|
27
|
+
@url = url || @config.ws_order_url
|
|
28
28
|
@connection_options = options
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -35,7 +35,7 @@ module DhanHQ
|
|
|
35
35
|
return self if @started.true?
|
|
36
36
|
|
|
37
37
|
@started.make_true
|
|
38
|
-
@conn = Connection.new(url: @url, **@connection_options)
|
|
38
|
+
@conn = Connection.new(url: @url, config: @config, **@connection_options)
|
|
39
39
|
@conn.on(:open) { emit(:open, true) }
|
|
40
40
|
@conn.on(:close) { |payload| emit(:close, payload) }
|
|
41
41
|
@conn.on(:error) { |error| emit(:error, error) }
|
|
@@ -63,6 +63,7 @@ module DhanHQ
|
|
|
63
63
|
# Process incoming WebSocket message
|
|
64
64
|
# @param ev [Event] WebSocket message event
|
|
65
65
|
def handle_message(ev)
|
|
66
|
+
WS.debug_frame(self.class.name, ev.data)
|
|
66
67
|
msg = JSON.parse(ev.data, symbolize_names: true)
|
|
67
68
|
emit(:raw, msg)
|
|
68
69
|
emit(:message, msg)
|
|
@@ -77,7 +78,7 @@ module DhanHQ
|
|
|
77
78
|
##
|
|
78
79
|
# Authenticate with DhanHQ Orders WebSocket
|
|
79
80
|
def authenticate
|
|
80
|
-
cfg = DhanHQ.configuration
|
|
81
|
+
cfg = @config || DhanHQ.configuration
|
|
81
82
|
|
|
82
83
|
if cfg.ws_user_type.to_s.upcase == "PARTNER"
|
|
83
84
|
payload = {
|
data/lib/DhanHQ/ws.rb
CHANGED
|
@@ -34,5 +34,30 @@ module DhanHQ
|
|
|
34
34
|
def self.disconnect_all_local!
|
|
35
35
|
Registry.stop_all
|
|
36
36
|
end
|
|
37
|
+
|
|
38
|
+
# Cap on how many bytes of a frame get hex-dumped by {debug_frame}. A full
|
|
39
|
+
# depth packet can run several KB; at tick frequency that floods the log
|
|
40
|
+
# long before it adds diagnostic value beyond the first couple hundred
|
|
41
|
+
# bytes (header + the first few fields is normally enough to spot a
|
|
42
|
+
# parsing bug). The full frame size is always logged regardless of the cap.
|
|
43
|
+
DEBUG_FRAME_MAX_BYTES = 256
|
|
44
|
+
|
|
45
|
+
# Logs a raw inbound WebSocket frame as a hex dump when
|
|
46
|
+
# +config.ws_debug+ (+DHAN_WS_DEBUG=true+) is enabled. A no-op otherwise --
|
|
47
|
+
# the flag is checked before any hex encoding work, so there's no cost
|
|
48
|
+
# when debug logging is off. Frames longer than {DEBUG_FRAME_MAX_BYTES}
|
|
49
|
+
# are truncated in the dump, but the logged byte count is always the full
|
|
50
|
+
# frame size.
|
|
51
|
+
#
|
|
52
|
+
# @param source [String] short tag identifying which connection the frame came from
|
|
53
|
+
# @param data [String] raw frame bytes
|
|
54
|
+
# @return [void]
|
|
55
|
+
def self.debug_frame(source, data)
|
|
56
|
+
return unless DhanHQ.configuration&.ws_debug?
|
|
57
|
+
|
|
58
|
+
hex = data.byteslice(0, DEBUG_FRAME_MAX_BYTES).unpack1("H*")
|
|
59
|
+
hex += "...truncated" if data.bytesize > DEBUG_FRAME_MAX_BYTES
|
|
60
|
+
DhanHQ.logger&.debug("[DhanHQ::WS::#{source}] frame (#{data.bytesize} bytes): #{hex}")
|
|
61
|
+
end
|
|
37
62
|
end
|
|
38
63
|
end
|