DhanHQ 3.4.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -1
- data/GUIDE.md +40 -0
- data/README.md +103 -0
- data/docs/CONFIGURATION.md +29 -2
- data/docs/ENDPOINTS_AND_SANDBOX.md +83 -38
- data/lib/DhanHQ/client.rb +101 -26
- data/lib/DhanHQ/concerns/order_audit.rb +26 -3
- data/lib/DhanHQ/configuration.rb +178 -27
- data/lib/DhanHQ/constants.rb +60 -1
- data/lib/DhanHQ/contracts/company_info_contract.rb +24 -0
- data/lib/DhanHQ/contracts/market_movers_contract.rb +57 -0
- data/lib/DhanHQ/contracts/news_headline_contract.rb +21 -0
- data/lib/DhanHQ/contracts/technical_data_contract.rb +25 -0
- data/lib/DhanHQ/core/base_api.rb +3 -17
- data/lib/DhanHQ/core/base_model.rb +32 -12
- data/lib/DhanHQ/dry_run/simulator.rb +3 -2
- data/lib/DhanHQ/helpers/attribute_helper.rb +0 -22
- data/lib/DhanHQ/helpers/request_helper.rb +13 -3
- data/lib/DhanHQ/models/option_chain.rb +7 -0
- data/lib/DhanHQ/models/order.rb +7 -7
- data/lib/DhanHQ/rate_limiter.rb +34 -40
- data/lib/DhanHQ/resources/global_stocks/funds.rb +1 -1
- data/lib/DhanHQ/resources/global_stocks/holdings.rb +1 -1
- data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +1 -1
- data/lib/DhanHQ/resources/global_stocks/market_status.rb +1 -1
- data/lib/DhanHQ/resources/global_stocks/orders.rb +1 -1
- data/lib/DhanHQ/resources/global_stocks/trades.rb +1 -1
- data/lib/DhanHQ/resources/market_feed.rb +1 -1
- data/lib/DhanHQ/resources/scanx.rb +98 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/base_connection.rb +2 -1
- data/lib/DhanHQ/ws/client.rb +10 -13
- data/lib/DhanHQ/ws/connection.rb +13 -15
- data/lib/DhanHQ/ws/market_depth/client.rb +9 -4
- data/lib/DhanHQ/ws/orders/client.rb +4 -4
- data/lib/DhanHQ/ws/orders/connection.rb +1 -1
- data/lib/DhanHQ/ws/registry.rb +10 -6
- data/lib/dhan_hq.rb +81 -48
- metadata +27 -22
data/lib/DhanHQ/client.rb
CHANGED
|
@@ -42,42 +42,82 @@ 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
|
-
|
|
52
|
+
# @param mode [Symbol, nil] Optional environment override (`:live`, `:sandbox`, `:hybrid`)
|
|
53
|
+
# for this client. When combined with +config:+ the configuration is forked,
|
|
54
|
+
# so a shared/global configuration is never mutated.
|
|
55
|
+
# @param attrs [Hash] Additional keyword configuration attributes
|
|
56
|
+
def initialize(config_or_attrs = nil, config: nil, api_type: :order_api, mode: nil, **attrs)
|
|
57
|
+
attrs = attrs.merge(mode: mode) if mode
|
|
58
|
+
@config = resolve_config(config_or_attrs, config, attrs)
|
|
59
|
+
@api_type = api_type
|
|
61
60
|
@rate_limiter = RateLimiter.for(api_type)
|
|
62
61
|
|
|
63
62
|
raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
# The Faraday connection used for HTTP requests.
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# happens when sandbox mode is toggled, or when a token endpoint hands back a
|
|
70
|
-
# different host mid-process.
|
|
66
|
+
# Built on first use and rebuilt whenever the base URL for this client's API
|
|
67
|
+
# tier changes — including a mid-flight mode switch.
|
|
71
68
|
#
|
|
72
69
|
# @return [Faraday::Connection]
|
|
73
70
|
def connection
|
|
74
|
-
current_url =
|
|
71
|
+
current_url = @config.base_url_for(@api_type)
|
|
75
72
|
return @connection if @connection && @last_base_url == current_url
|
|
76
73
|
|
|
77
74
|
@last_base_url = current_url
|
|
78
75
|
@connection = build_connection(current_url)
|
|
79
76
|
end
|
|
80
77
|
|
|
78
|
+
# Resource accessors bound to this client instance. Each resource gets the client
|
|
79
|
+
# matching its own API_TYPE, so e.g. option chain calls throttle at 1 per 3 sec
|
|
80
|
+
# instead of borrowing the order client's 10/sec limiter.
|
|
81
|
+
def orders = @orders ||= Resources::Orders.new(client: client_for(Resources::Orders::API_TYPE))
|
|
82
|
+
def market_feed = @market_feed ||= Resources::MarketFeed.new(client: client_for(Resources::MarketFeed::API_TYPE))
|
|
83
|
+
def positions = @positions ||= Resources::Positions.new(client: client_for(Resources::Positions::API_TYPE))
|
|
84
|
+
def holdings = @holdings ||= Resources::Holdings.new(client: client_for(Resources::Holdings::API_TYPE))
|
|
85
|
+
def historical_data = @historical_data ||= Resources::HistoricalData.new(client: client_for(Resources::HistoricalData::API_TYPE))
|
|
86
|
+
def option_chain = @option_chain ||= Resources::OptionChain.new(client: client_for(Resources::OptionChain::API_TYPE))
|
|
87
|
+
def funds = @funds ||= Resources::Funds.new(client: client_for(Resources::Funds::API_TYPE))
|
|
88
|
+
def trades = @trades ||= Resources::Trades.new(client: client_for(Resources::Trades::API_TYPE))
|
|
89
|
+
def forever_orders = @forever_orders ||= Resources::ForeverOrders.new(client: client_for(Resources::ForeverOrders::API_TYPE))
|
|
90
|
+
def alert_orders = @alert_orders ||= Resources::AlertOrders.new(client: client_for(Resources::AlertOrders::API_TYPE))
|
|
91
|
+
def iceberg_orders = @iceberg_orders ||= Resources::IcebergOrders.new(client: client_for(Resources::IcebergOrders::API_TYPE))
|
|
92
|
+
def super_orders = @super_orders ||= Resources::SuperOrders.new(client: client_for(Resources::SuperOrders::API_TYPE))
|
|
93
|
+
def twap_orders = @twap_orders ||= Resources::TwapOrders.new(client: client_for(Resources::TwapOrders::API_TYPE))
|
|
94
|
+
def statements = @statements ||= Resources::Statements.new(client: client_for(Resources::Statements::API_TYPE))
|
|
95
|
+
def kill_switch = @kill_switch ||= Resources::KillSwitch.new(client: client_for(Resources::KillSwitch::API_TYPE))
|
|
96
|
+
def pnl_exit = @pnl_exit ||= Resources::PnlExit.new(client: client_for(Resources::PnlExit::API_TYPE))
|
|
97
|
+
def trader_control = @trader_control ||= Resources::TraderControl.new(client: client_for(Resources::TraderControl::API_TYPE))
|
|
98
|
+
def ip_setup = @ip_setup ||= Resources::IPSetup.new(client: client_for(Resources::IPSetup::API_TYPE))
|
|
99
|
+
def edis = @edis ||= Resources::Edis.new(client: client_for(Resources::Edis::API_TYPE))
|
|
100
|
+
def scanx = @scanx ||= Resources::ScanX.new(client: client_for(Resources::ScanX::API_TYPE))
|
|
101
|
+
|
|
102
|
+
# The effective environment (+:live+ or +:sandbox+) for requests issued by
|
|
103
|
+
# this client, resolved from its API tier and the configured mode.
|
|
104
|
+
#
|
|
105
|
+
# @return [Symbol]
|
|
106
|
+
def environment
|
|
107
|
+
@config.environment_for(@api_type)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# WebSocket streams bound to this client instance configuration
|
|
111
|
+
def ws_market_feed(mode: :ticker, url: nil, &block)
|
|
112
|
+
WS::Client.new(mode: mode, url: url, config: @config).tap do |ws|
|
|
113
|
+
ws.start if block_given?
|
|
114
|
+
ws.on(:tick, &block) if block_given?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ws_order_update = WS::Orders::Client.new(config: @config)
|
|
119
|
+
def ws_market_depth = WS::MarketDepth::Client.new(config: @config)
|
|
120
|
+
|
|
81
121
|
# Sends an HTTP request to the API with automatic retry for transient errors.
|
|
82
122
|
#
|
|
83
123
|
# @param method [Symbol] The HTTP method (`:get`, `:post`, `:put`, `:delete`)
|
|
@@ -93,8 +133,6 @@ module DhanHQ
|
|
|
93
133
|
@token_manager&.ensure_valid_token!
|
|
94
134
|
@rate_limiter.throttle!
|
|
95
135
|
|
|
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
136
|
effective_retries = retryable_write?(method, path) ? retries : 0
|
|
99
137
|
|
|
100
138
|
with_auth_retry do
|
|
@@ -112,10 +150,9 @@ module DhanHQ
|
|
|
112
150
|
yield
|
|
113
151
|
rescue DhanHQ::InvalidAuthenticationError, DhanHQ::InvalidTokenError,
|
|
114
152
|
DhanHQ::TokenExpiredError, DhanHQ::AuthenticationFailedError => e
|
|
115
|
-
|
|
116
|
-
raise unless config&.access_token_provider
|
|
153
|
+
raise unless @config&.access_token_provider
|
|
117
154
|
|
|
118
|
-
config.on_token_expired&.call(e)
|
|
155
|
+
@config.on_token_expired&.call(e)
|
|
119
156
|
DhanHQ.logger&.warn("[DhanHQ::Client] Auth failure (#{e.class}), retrying once with fresh token")
|
|
120
157
|
yield
|
|
121
158
|
end
|
|
@@ -218,20 +255,58 @@ module DhanHQ
|
|
|
218
255
|
@token_manager.generate!
|
|
219
256
|
end
|
|
220
257
|
|
|
258
|
+
# A client sharing this one's config but rate-limited for a different API tier.
|
|
259
|
+
# RateLimiter.for(api_type) is a shared instance per type, so sibling clients still
|
|
260
|
+
# throttle against the same counters as any other client using that tier.
|
|
261
|
+
#
|
|
262
|
+
# @param api_type [Symbol] Target API tier (:order_api, :data_api, :non_trading_api, :option_chain)
|
|
263
|
+
# @return [DhanHQ::Client]
|
|
264
|
+
def client_for(api_type)
|
|
265
|
+
return self if api_type == @api_type
|
|
266
|
+
|
|
267
|
+
@sibling_clients ||= {}
|
|
268
|
+
@sibling_clients[api_type] ||= self.class.new(config: @config, api_type: api_type)
|
|
269
|
+
end
|
|
270
|
+
|
|
221
271
|
private
|
|
222
272
|
|
|
273
|
+
def resolve_config(config_or_attrs, keyword_config, attrs = {})
|
|
274
|
+
if config_or_attrs.is_a?(DhanHQ::Configuration)
|
|
275
|
+
fork_config_with_attrs(config_or_attrs, attrs)
|
|
276
|
+
elsif config_or_attrs.is_a?(Hash)
|
|
277
|
+
DhanHQ::Configuration.new(config_or_attrs.merge(attrs))
|
|
278
|
+
elsif keyword_config
|
|
279
|
+
fork_config_with_attrs(keyword_config, attrs)
|
|
280
|
+
elsif attrs.any?
|
|
281
|
+
DhanHQ::Configuration.new(attrs)
|
|
282
|
+
else
|
|
283
|
+
DhanHQ.ensure_configuration!
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Copies a Configuration when per-instance attributes (e.g. +mode:+) are
|
|
288
|
+
# given alongside it, so an injected or global configuration is never
|
|
289
|
+
# mutated. Without extra attributes the original object is shared unchanged.
|
|
290
|
+
def fork_config_with_attrs(config, attrs)
|
|
291
|
+
return config if attrs.empty?
|
|
292
|
+
|
|
293
|
+
config.dup.tap do |forked|
|
|
294
|
+
attrs.each { |key, value| forked.public_send("#{key}=", value) }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
223
298
|
# Simulator that answers state-changing requests locally when dry run is on.
|
|
224
299
|
#
|
|
225
300
|
# @return [DhanHQ::DryRun::Simulator]
|
|
226
301
|
def dry_run
|
|
227
|
-
@dry_run ||= DhanHQ::DryRun::Simulator.new
|
|
302
|
+
@dry_run ||= DhanHQ::DryRun::Simulator.new(config: @config)
|
|
228
303
|
end
|
|
229
304
|
|
|
230
305
|
# A write may be auto-retried only when the user has explicitly opted in.
|
|
231
306
|
def retryable_write?(method, path)
|
|
232
307
|
return true unless WritePaths.mutating?(method, path)
|
|
233
308
|
|
|
234
|
-
|
|
309
|
+
@config&.retry_non_idempotent_writes? || false
|
|
235
310
|
end
|
|
236
311
|
|
|
237
312
|
# Fills in a +correlationId+ on order placements that lack one, so a caller who
|
|
@@ -240,7 +315,7 @@ module DhanHQ
|
|
|
240
315
|
def with_correlation_id(method, path, payload)
|
|
241
316
|
return payload unless WritePaths.order_placement?(method, path)
|
|
242
317
|
return payload unless payload.is_a?(Hash)
|
|
243
|
-
return payload unless
|
|
318
|
+
return payload unless @config&.auto_correlation_id?
|
|
244
319
|
return payload if correlation_id?(payload)
|
|
245
320
|
|
|
246
321
|
inject_correlation_id(payload)
|
|
@@ -37,13 +37,28 @@ module DhanHQ
|
|
|
37
37
|
module OrderAudit
|
|
38
38
|
private
|
|
39
39
|
|
|
40
|
-
# Raises an error
|
|
41
|
-
#
|
|
40
|
+
# Raises an error unless live trading is explicitly enabled OR the request
|
|
41
|
+
# targets the sandbox host, where no real money is at risk.
|
|
42
|
+
#
|
|
43
|
+
# Set ENV["LIVE_TRADING"]="true" to allow order submission against the
|
|
44
|
+
# production host. Orders routed to the sandbox host (config.mode =
|
|
45
|
+
# :sandbox, or the trading tiers under :hybrid) skip the gate because they
|
|
46
|
+
# cannot place real orders.
|
|
42
47
|
def ensure_live_trading!
|
|
43
48
|
return if ENV["LIVE_TRADING"] == "true"
|
|
49
|
+
return if sandbox_target?
|
|
44
50
|
|
|
45
51
|
raise DhanHQ::LiveTradingDisabledError,
|
|
46
|
-
"Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement
|
|
52
|
+
"Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement, " \
|
|
53
|
+
"or switch to the sandbox with DhanHQ.configure { |c| c.mode = :sandbox } / :hybrid."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# True when this resource's requests resolve to the sandbox host.
|
|
57
|
+
# Tolerates hosts that never got a client wired in (bare test doubles).
|
|
58
|
+
def sandbox_target?
|
|
59
|
+
return false unless respond_to?(:client) && client.respond_to?(:environment)
|
|
60
|
+
|
|
61
|
+
client.environment == :sandbox
|
|
47
62
|
end
|
|
48
63
|
|
|
49
64
|
# Runs the risk pipeline for the given order params.
|
|
@@ -120,6 +135,7 @@ module DhanHQ
|
|
|
120
135
|
event: event,
|
|
121
136
|
hostname: inspector.hostname,
|
|
122
137
|
env: inspector.environment,
|
|
138
|
+
target: request_target,
|
|
123
139
|
ipv4: inspector.public_ipv4,
|
|
124
140
|
ipv6: inspector.public_ipv6,
|
|
125
141
|
security_id: extract_param(params, :securityId, :security_id),
|
|
@@ -131,6 +147,13 @@ module DhanHQ
|
|
|
131
147
|
DhanHQ.logger&.warn(JSON.generate(entry))
|
|
132
148
|
end
|
|
133
149
|
|
|
150
|
+
# The host the order will be sent to, for the audit trail.
|
|
151
|
+
def request_target
|
|
152
|
+
return "sandbox" if sandbox_target?
|
|
153
|
+
|
|
154
|
+
"live"
|
|
155
|
+
end
|
|
156
|
+
|
|
134
157
|
# Extracts a value from params trying both camelCase and snake_case keys,
|
|
135
158
|
# as well as both symbol and string key types.
|
|
136
159
|
def extract_param(params, camel_key, snake_key)
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -15,6 +15,24 @@ module DhanHQ
|
|
|
15
15
|
# Default Sandbox API host.
|
|
16
16
|
# @return [String]
|
|
17
17
|
SANDBOX_URL = Constants::Urls::SANDBOX_API_BASE
|
|
18
|
+
|
|
19
|
+
# All environments a client can run in.
|
|
20
|
+
#
|
|
21
|
+
# - +:live+ — every request hits the production host.
|
|
22
|
+
# - +:sandbox+ — every request hits the sandbox host (previous `sandbox = true` behaviour).
|
|
23
|
+
# - +:hybrid+ — market data (quotes, option chain, charts, instruments, ScanX)
|
|
24
|
+
# hits production while trading/account requests hit the sandbox host, so a
|
|
25
|
+
# strategy rehearses execution against real prices without risking money.
|
|
26
|
+
# @return [Array<Symbol>]
|
|
27
|
+
MODES = %i[live sandbox hybrid].freeze
|
|
28
|
+
|
|
29
|
+
# API tiers routed to the production host when {#mode} is +:hybrid+.
|
|
30
|
+
# Everything else (orders, positions, funds, statements, ...) routes to the
|
|
31
|
+
# sandbox host. Global Stocks keeps its own tier so it stays on production in
|
|
32
|
+
# hybrid mode — the sandbox host does not mirror the US-equities API.
|
|
33
|
+
# @return [Array<Symbol>]
|
|
34
|
+
HYBRID_LIVE_API_TYPES = %i[data_api quote_api option_chain global_stocks_api].freeze
|
|
35
|
+
|
|
18
36
|
# The client ID for API authentication.
|
|
19
37
|
# @return [String, nil] The client ID or `nil` if not set.
|
|
20
38
|
attr_accessor :client_id
|
|
@@ -39,7 +57,55 @@ module DhanHQ
|
|
|
39
57
|
attr_writer :base_url
|
|
40
58
|
|
|
41
59
|
# Whether to use the sandbox environment.
|
|
42
|
-
|
|
60
|
+
#
|
|
61
|
+
# Backward-compatible facade over {#mode}: setting it to +true+ switches to
|
|
62
|
+
# full sandbox mode, +false+ back to live. Prefer {#mode} for new code — it
|
|
63
|
+
# also supports +:hybrid+ (live market data + sandbox execution).
|
|
64
|
+
#
|
|
65
|
+
# rubocop:disable Naming/PredicateMethod -- legacy attr_accessor-style API,
|
|
66
|
+
# kept as a boolean mirror of +mode+ for every caller written before :hybrid.
|
|
67
|
+
# @return [Boolean] True when every request targets the sandbox host.
|
|
68
|
+
def sandbox
|
|
69
|
+
mode == :sandbox
|
|
70
|
+
end
|
|
71
|
+
# rubocop:enable Naming/PredicateMethod
|
|
72
|
+
|
|
73
|
+
def sandbox=(value)
|
|
74
|
+
self.mode = value ? :sandbox : :live
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The environment the client runs in: +:live+, +:sandbox+, or +:hybrid+.
|
|
78
|
+
#
|
|
79
|
+
# Set via +DHAN_MODE+ or in {DhanHQ.configure}. {#sandbox=} remains as an
|
|
80
|
+
# alias for switching between +:live+ and +:sandbox+.
|
|
81
|
+
#
|
|
82
|
+
# @return [Symbol]
|
|
83
|
+
attr_reader :mode
|
|
84
|
+
|
|
85
|
+
# @param value [Symbol, String] One of {MODES} (case-insensitive).
|
|
86
|
+
# @raise [ArgumentError] When the value is not a known mode.
|
|
87
|
+
def mode=(value)
|
|
88
|
+
@mode = coerce_mode!(value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Per-tier environment overrides applied on top of {#mode}.
|
|
92
|
+
#
|
|
93
|
+
# Keys are API tier symbols (+:order_api+, +:data_api+, +:quote_api+,
|
|
94
|
+
# +:option_chain+, +:non_trading_api+, +:global_stocks_api+); values are
|
|
95
|
+
# +:live+ or +:sandbox+. Useful to, say, keep fund limits on production while
|
|
96
|
+
# everything else runs on the sandbox in hybrid mode:
|
|
97
|
+
#
|
|
98
|
+
# DhanHQ.configure do |c|
|
|
99
|
+
# c.mode = :hybrid
|
|
100
|
+
# c.environment_overrides = { non_trading_api: :live }
|
|
101
|
+
# end
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash{Symbol => Symbol}]
|
|
104
|
+
attr_reader :environment_overrides
|
|
105
|
+
|
|
106
|
+
def environment_overrides=(value)
|
|
107
|
+
@environment_overrides = coerce_environment_overrides(value)
|
|
108
|
+
end
|
|
43
109
|
|
|
44
110
|
# When true, state-changing requests (order placement, modification,
|
|
45
111
|
# cancellation, position exits, trading controls) are validated and logged but
|
|
@@ -176,15 +242,54 @@ module DhanHQ
|
|
|
176
242
|
# is nil or the default production URL, returns {SANDBOX_URL}.
|
|
177
243
|
# @return [String]
|
|
178
244
|
def base_url
|
|
179
|
-
|
|
245
|
+
base_url_for(nil)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Returns the base URL for requests on the given API tier, honouring
|
|
249
|
+
# {#mode}, {#environment_overrides}, and an explicit {#base_url} override.
|
|
250
|
+
#
|
|
251
|
+
# In +:hybrid+ mode data tiers (see {HYBRID_LIVE_API_TYPES}) resolve to the
|
|
252
|
+
# production host while trading and account tiers resolve to the sandbox
|
|
253
|
+
# host, which is what lets a strategy read live prices while placing paper
|
|
254
|
+
# orders.
|
|
255
|
+
#
|
|
256
|
+
# @param api_type [Symbol, nil] API tier the request belongs to. +nil+ uses
|
|
257
|
+
# the mode-level default (+:sandbox+ mode resolves to the sandbox host,
|
|
258
|
+
# +:live+ and +:hybrid+ to production).
|
|
259
|
+
# @return [String]
|
|
260
|
+
def base_url_for(api_type)
|
|
261
|
+
if environment_for(api_type) == :sandbox && (@base_url.nil? || @base_url == BASE_URL)
|
|
180
262
|
SANDBOX_URL
|
|
181
263
|
else
|
|
182
264
|
@base_url || BASE_URL
|
|
183
265
|
end
|
|
184
266
|
end
|
|
185
267
|
|
|
268
|
+
# The effective environment (+:live+ or +:sandbox+) for requests on the
|
|
269
|
+
# given API tier. Explicit {#environment_overrides} win over the {#mode}
|
|
270
|
+
# defaults.
|
|
271
|
+
#
|
|
272
|
+
# @param api_type [Symbol, nil]
|
|
273
|
+
# @return [Symbol]
|
|
274
|
+
def environment_for(api_type)
|
|
275
|
+
key = api_type&.to_sym
|
|
276
|
+
return @environment_overrides[key] if key && @environment_overrides.key?(key)
|
|
277
|
+
|
|
278
|
+
case mode
|
|
279
|
+
when :sandbox then :sandbox
|
|
280
|
+
when :hybrid then HYBRID_LIVE_API_TYPES.include?(key) ? :live : :sandbox
|
|
281
|
+
else :live
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
186
285
|
def sandbox?
|
|
187
|
-
|
|
286
|
+
mode == :sandbox
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# True when {#mode} is +:hybrid+ — live market data, sandbox execution.
|
|
290
|
+
# @return [Boolean]
|
|
291
|
+
def hybrid?
|
|
292
|
+
mode == :hybrid
|
|
188
293
|
end
|
|
189
294
|
|
|
190
295
|
# @return [Boolean] True when state-changing requests should be simulated.
|
|
@@ -212,34 +317,80 @@ module DhanHQ
|
|
|
212
317
|
@ws_debug == true
|
|
213
318
|
end
|
|
214
319
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
@
|
|
223
|
-
@
|
|
224
|
-
@
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
@
|
|
231
|
-
@
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
@
|
|
235
|
-
@
|
|
236
|
-
@
|
|
237
|
-
@
|
|
238
|
-
@
|
|
320
|
+
def initialize(attrs = {})
|
|
321
|
+
@client_id = attrs[:client_id] || ENV.fetch("DHAN_CLIENT_ID", nil)
|
|
322
|
+
@access_token = attrs[:access_token] || ENV.fetch("DHAN_ACCESS_TOKEN", nil)
|
|
323
|
+
@access_token_provider = attrs[:access_token_provider]
|
|
324
|
+
@on_token_expired = attrs[:on_token_expired]
|
|
325
|
+
@sandbox = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
|
|
326
|
+
@dry_run = attrs.key?(:dry_run) ? attrs[:dry_run] : env_flag("DHAN_DRY_RUN", default: false)
|
|
327
|
+
@retry_non_idempotent_writes = attrs.key?(:retry_non_idempotent_writes) ? attrs[:retry_non_idempotent_writes] : env_flag("DHAN_RETRY_WRITES", default: false)
|
|
328
|
+
@auto_correlation_id = attrs.key?(:auto_correlation_id) ? attrs[:auto_correlation_id] : env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
|
|
329
|
+
@warn_on_ambiguous_write_failure = if attrs.key?(:warn_on_ambiguous_write_failure)
|
|
330
|
+
attrs[:warn_on_ambiguous_write_failure]
|
|
331
|
+
else
|
|
332
|
+
env_flag("DHAN_WARN_AMBIGUOUS_WRITE_FAILURE",
|
|
333
|
+
default: true)
|
|
334
|
+
end
|
|
335
|
+
@base_url = attrs[:base_url] || ENV.fetch("DHAN_BASE_URL", nil)
|
|
336
|
+
@environment_overrides = coerce_environment_overrides(
|
|
337
|
+
attrs[:environment_overrides] || ENV.fetch("DHAN_ENV_OVERRIDES", nil)
|
|
338
|
+
)
|
|
339
|
+
@mode = resolve_mode(attrs)
|
|
340
|
+
@ws_version = (attrs[:ws_version] || ENV.fetch("DHAN_WS_VERSION", 2)).to_i
|
|
341
|
+
@ws_order_url = attrs[:ws_order_url] || ENV.fetch("DHAN_WS_ORDER_URL", nil)
|
|
342
|
+
@ws_market_feed_url = attrs[:ws_market_feed_url] || ENV.fetch("DHAN_WS_MARKET_FEED_URL", nil)
|
|
343
|
+
@ws_market_depth_url = attrs[:ws_market_depth_url] || ENV.fetch("DHAN_WS_MARKET_DEPTH_URL", nil)
|
|
344
|
+
@market_depth_level = (attrs[:market_depth_level] || ENV.fetch("DHAN_MARKET_DEPTH_LEVEL", "20")).to_i
|
|
345
|
+
@ws_debug = attrs.key?(:ws_debug) ? attrs[:ws_debug] : env_flag("DHAN_WS_DEBUG", default: false)
|
|
346
|
+
@ws_user_type = attrs[:ws_user_type] || ENV.fetch("DHAN_WS_USER_TYPE", "SELF")
|
|
347
|
+
@partner_id = attrs[:partner_id] || ENV.fetch("DHAN_PARTNER_ID", nil)
|
|
348
|
+
@partner_secret = attrs[:partner_secret] || ENV.fetch("DHAN_PARTNER_SECRET", nil)
|
|
349
|
+
yield(self) if block_given?
|
|
239
350
|
end
|
|
240
351
|
|
|
241
352
|
private
|
|
242
353
|
|
|
354
|
+
# Resolves the initial mode from attributes and ENV.
|
|
355
|
+
#
|
|
356
|
+
# Precedence: an explicit +:mode+ attribute wins, then +DHAN_MODE+, then the
|
|
357
|
+
# legacy +sandbox+ attribute / +DHAN_SANDBOX+ flag, then +:live+.
|
|
358
|
+
def resolve_mode(attrs)
|
|
359
|
+
raw_mode = attrs.key?(:mode) ? attrs[:mode] : ENV.fetch("DHAN_MODE", nil)
|
|
360
|
+
return coerce_mode!(raw_mode) if raw_mode
|
|
361
|
+
|
|
362
|
+
sandbox_flag = attrs.key?(:sandbox) ? attrs[:sandbox] : env_flag("DHAN_SANDBOX", default: false)
|
|
363
|
+
sandbox_flag ? :sandbox : :live
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Normalises a mode value, raising on anything outside {MODES}.
|
|
367
|
+
def coerce_mode!(value)
|
|
368
|
+
mode = value.to_s.strip.downcase.to_sym
|
|
369
|
+
raise ArgumentError, "unknown mode #{value.inspect}: expected one of #{MODES.join(", ")}" unless MODES.include?(mode)
|
|
370
|
+
|
|
371
|
+
mode
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Valid targets for an environment override.
|
|
375
|
+
OVERRIDE_TARGETS = %i[live sandbox].freeze
|
|
376
|
+
private_constant :OVERRIDE_TARGETS
|
|
377
|
+
|
|
378
|
+
# Normalises an environment-overrides hash: symbol keys, :live/:sandbox values.
|
|
379
|
+
def coerce_environment_overrides(value)
|
|
380
|
+
return {} if value.nil? || value == ""
|
|
381
|
+
|
|
382
|
+
value = value.split(",").to_h { |pair| pair.split("=") } if value.is_a?(String)
|
|
383
|
+
raise ArgumentError, "environment_overrides must be a Hash (tier => :live/:sandbox)" unless value.is_a?(Hash)
|
|
384
|
+
|
|
385
|
+
value.each_with_object({}) do |(tier, env), out|
|
|
386
|
+
key = tier.to_s.strip.to_sym
|
|
387
|
+
target = env.to_s.strip.downcase.to_sym
|
|
388
|
+
raise ArgumentError, "environment override for #{key} must be :live or :sandbox (got #{env.inspect})" unless OVERRIDE_TARGETS.include?(target)
|
|
389
|
+
|
|
390
|
+
out[key] = target
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
243
394
|
# Reads a boolean-ish environment variable, falling back to +default+ when unset.
|
|
244
395
|
def env_flag(name, default:)
|
|
245
396
|
raw = ENV.fetch(name, nil)
|
data/lib/DhanHQ/constants.rb
CHANGED
|
@@ -130,6 +130,63 @@ module DhanHQ
|
|
|
130
130
|
# Backward-compatible alias kept for existing SDK usage.
|
|
131
131
|
Instrument = InstrumentType
|
|
132
132
|
|
|
133
|
+
# ScanX data API enums (POST /v2/data/* — company info, market movers,
|
|
134
|
+
# news headlines, technical indicators).
|
|
135
|
+
module ScanX
|
|
136
|
+
# Exchange segments accepted by POST /v2/data/companyinfo.
|
|
137
|
+
COMPANY_INFO_SEGMENTS = %w[NSE_EQ BSE_EQ].freeze
|
|
138
|
+
# Metric sections returned by POST /v2/data/companyinfo (CO = company
|
|
139
|
+
# overview, RATIOS = valuation/profitability + industry benchmarks,
|
|
140
|
+
# SHP = shareholding pattern).
|
|
141
|
+
COMPANY_INFO_METRICS = %w[CO RATIOS SHP].freeze
|
|
142
|
+
# Instrument kinds accepted by POST /v2/data/companyinfo.
|
|
143
|
+
COMPANY_INFO_INSTRUMENTS = %w[EQUITY].freeze
|
|
144
|
+
|
|
145
|
+
# Exchange segments accepted by POST /v2/data/marketmovers.
|
|
146
|
+
MARKET_MOVERS_SEGMENTS = %w[NSE_FNO BSE_FNO NSE_COMM MCX_COMM NSE_EQ BSE_EQ].freeze
|
|
147
|
+
# Instrument kinds ranked by POST /v2/data/marketmovers.
|
|
148
|
+
MARKET_MOVERS_INSTRUMENTS = %w[OPTIDX OPTSTK OPTFUT FUTIDX FUTSTK FUTCOM EQUITY].freeze
|
|
149
|
+
# Ranking categories for POST /v2/data/marketmovers.
|
|
150
|
+
MARKET_MOVERS_CATEGORIES =
|
|
151
|
+
%w[HIGHEST_OI OI_GAINERS OI_LOSERS TOP_VOLUME PRICE_GAINERS PRICE_LOSERS].freeze
|
|
152
|
+
# Equity universes accepted by POST /v2/data/marketmovers (required when
|
|
153
|
+
# instrument is EQUITY).
|
|
154
|
+
MARKET_MOVERS_UNIVERSES = %w[
|
|
155
|
+
ALL FNO_STOCKS NIFTY_50 NIFTY_BANK FINNIFTY INDIA_VIX NIFTY_MIDCAP NIFTY_NEXT_50
|
|
156
|
+
NIFTY_SMALLCAP_50 NIFTY_MID_CAP_50 NIFTY_100 NIFTY_200 NIFTY_500 NIFTY_MIDCAP_100
|
|
157
|
+
NIFTY_MIDCAP_150 NIFTY_SMALLCAP_100 NIFTY_SMALLCAP_250 NIFTY_MICROCAP_250 NIFTY_AUTO
|
|
158
|
+
NIFTY_PRIVATE_BANK NIFTY_FMCG NIFTY_ENERGY NIFTY_INFRA NIFTY_IT NIFTY_MEDIA NIFTY_METAL
|
|
159
|
+
NIFTY_MNC NIFTY_PHARMA NIFTY_PSU_BANK NIFTY_REALTY NIFTY_SERVICE_SECTOR NIFTY_CUNSUMPTION
|
|
160
|
+
GIFT_NIFTY SENSEX BSE_100 BSE_200 BSE_500 BSE_150_MIDCAP BSE_250_SMALLCAP
|
|
161
|
+
BSE_250_LARGE_MID BSE_400_MID_SMALL BSE_BANKEX BSE_AUTO BSE_CAPITAL_GOODS
|
|
162
|
+
BSE_CONSUMER_DURABLES BSE_ENERGY BSE_FINANCE BSE_FMCG BSE_HEALTHCARE BSE_INDIA_MFG
|
|
163
|
+
BSE_INDUSTRIALS BSE_IPO BSE_IT BSE_METALS BSE_OIL_AND_GAS BSE_POWER BSE_PSU BSE_TELECOM
|
|
164
|
+
].freeze
|
|
165
|
+
|
|
166
|
+
# News categories accepted by POST /v2/data/newsheadline.
|
|
167
|
+
NEWS_CATEGORIES = %w[
|
|
168
|
+
ALL COMPANIES EQUITY_MARKETS DEBT_MARKETS IPO GLOBAL INDIAN_ECONOMY GLOBAL_ECONOMY
|
|
169
|
+
CURRENCY COMMODITIES CRYPTOCURRENCIES GOVERNMENT_POLICY_AND_REGULATION INFRASTRUCTURE
|
|
170
|
+
INTERNATIONAL_TRADE INVESTMENT_IDEAS REAL_ESTATE STARTUPS TECHNOLOGY MUTUAL_FUNDS
|
|
171
|
+
].freeze
|
|
172
|
+
# News scoping options (portfolio/watchlist) for POST /v2/data/newsheadline.
|
|
173
|
+
NEWS_UNIVERSES = %w[PORTFOLIO WATCHLIST].freeze
|
|
174
|
+
|
|
175
|
+
# Exchange segments accepted by POST /v2/data/technical.
|
|
176
|
+
TECHNICAL_SEGMENTS = %w[NSE_EQ IDX_I].freeze
|
|
177
|
+
# Instrument kinds accepted by POST /v2/data/technical.
|
|
178
|
+
TECHNICAL_INSTRUMENTS = %w[INDEX EQUITY].freeze
|
|
179
|
+
# Candle timeframes for POST /v2/data/technical (1/5/15 minute, D = daily).
|
|
180
|
+
TECHNICAL_TIMEFRAMES = %w[1 5 15 D].freeze
|
|
181
|
+
# Indicator identifiers computed by POST /v2/data/technical.
|
|
182
|
+
TECHNICAL_INDICATORS = %w[
|
|
183
|
+
SMA_5 SMA_10 SMA_20 SMA_50 SMA_100 SMA_200
|
|
184
|
+
EMA_5 EMA_10 EMA_20 EMA_50 EMA_100 EMA_200
|
|
185
|
+
RSI_14 MACD_HIST STOCH STOCHRSI_14 ATR_14 ADX_14 UO ROC WILLR
|
|
186
|
+
PIVOT_CLASSIC PIVOT_FIBONACCI PIVOT_CAMARILLA
|
|
187
|
+
].freeze
|
|
188
|
+
end
|
|
189
|
+
|
|
133
190
|
# Minute intervals allowed by POST /v2/charts/intraday (charts annexure).
|
|
134
191
|
module ChartInterval
|
|
135
192
|
ONE = "1"
|
|
@@ -536,7 +593,8 @@ module DhanHQ
|
|
|
536
593
|
"/v2/marketfeed/",
|
|
537
594
|
"/v2/optionchain",
|
|
538
595
|
"/v2/instrument/",
|
|
539
|
-
"/v2/charts"
|
|
596
|
+
"/v2/charts",
|
|
597
|
+
"/v2/data"
|
|
540
598
|
].freeze
|
|
541
599
|
|
|
542
600
|
# Path prefixes for which the request body (POST/PUT/PATCH) must include dhanClientId.
|
|
@@ -555,6 +613,7 @@ module DhanHQ
|
|
|
555
613
|
/v2/killswitch
|
|
556
614
|
/v2/ip
|
|
557
615
|
/v2/globalstocks
|
|
616
|
+
/v2/data/newsheadline
|
|
558
617
|
].freeze
|
|
559
618
|
|
|
560
619
|
# Path prefixes whose non-GET requests change real account state — they place,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Contracts
|
|
5
|
+
# Validates request for POST /v2/data/companyinfo (ScanX company fundamentals).
|
|
6
|
+
#
|
|
7
|
+
# securityId (digits), exchangeSegment (NSE_EQ/BSE_EQ), instrument (EQUITY),
|
|
8
|
+
# metrics (one or more of CO / RATIOS / SHP).
|
|
9
|
+
class CompanyInfoContract < BaseContract
|
|
10
|
+
params do
|
|
11
|
+
required(:security_id).filled(:string)
|
|
12
|
+
required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::COMPANY_INFO_SEGMENTS)
|
|
13
|
+
required(:instrument).filled(:string, included_in?: Constants::ScanX::COMPANY_INFO_INSTRUMENTS)
|
|
14
|
+
required(:metrics).filled(:array, min_size?: 1) do
|
|
15
|
+
each(:str?, included_in?: Constants::ScanX::COMPANY_INFO_METRICS)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
rule(:security_id) do
|
|
20
|
+
key.failure("must be a numeric security id string") unless value.to_s.match?(/\A\d+\z/)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validates request for POST /v2/data/marketmovers (ScanX ranked instruments).
|
|
8
|
+
#
|
|
9
|
+
# exchangeSegment, instrument[] (options / futures / equity kinds, no mixing
|
|
10
|
+
# groups), category, limit (1-100). Expiry is required for options/futures;
|
|
11
|
+
# universe is required for EQUITY and ignored otherwise.
|
|
12
|
+
class MarketMoversContract < BaseContract
|
|
13
|
+
DERIVATIVE_INSTRUMENTS = %w[OPTIDX OPTSTK OPTFUT FUTIDX FUTSTK FUTCOM].freeze
|
|
14
|
+
private_constant :DERIVATIVE_INSTRUMENTS
|
|
15
|
+
|
|
16
|
+
params do
|
|
17
|
+
required(:exchange_segment).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_SEGMENTS)
|
|
18
|
+
required(:instrument).filled(:array, min_size?: 1) do
|
|
19
|
+
each(:str?, included_in?: Constants::ScanX::MARKET_MOVERS_INSTRUMENTS)
|
|
20
|
+
end
|
|
21
|
+
required(:category).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_CATEGORIES)
|
|
22
|
+
required(:limit).filled(:integer, included_in?: 1..100)
|
|
23
|
+
optional(:expiry).filled(:string)
|
|
24
|
+
optional(:universe).filled(:string, included_in?: Constants::ScanX::MARKET_MOVERS_UNIVERSES)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
rule(:expiry) do
|
|
28
|
+
next unless value.is_a?(String)
|
|
29
|
+
|
|
30
|
+
unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
31
|
+
key.failure("must be in YYYY-MM-DD format")
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Date.parse(value)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
key.failure("must be a valid date")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Expiry is required when ranking derivatives; universe is required for EQUITY.
|
|
41
|
+
rule(:instrument, :expiry) do
|
|
42
|
+
instruments = Array(values[:instrument]).map(&:to_s)
|
|
43
|
+
key(:expiry).failure("is required when instrument includes options or futures") if derivative?(instruments) && values[:expiry].to_s.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rule(:instrument, :universe) do
|
|
47
|
+
instruments = Array(values[:instrument]).map(&:to_s)
|
|
48
|
+
key(:universe).failure("is required when instrument is EQUITY") if instruments.include?(Constants::InstrumentType::EQUITY) && values[:universe].to_s.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# True when any requested instrument is an option or future.
|
|
52
|
+
def derivative?(instruments)
|
|
53
|
+
instruments.any? { |i| DERIVATIVE_INSTRUMENTS.include?(i) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|