DhanHQ 3.1.0 → 3.2.1
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/ARCHITECTURE.md +47 -1
- data/CHANGELOG.md +73 -0
- data/README.md +241 -3
- data/docs/AUTHENTICATION.md +3 -5
- data/docs/CONFIGURATION.md +3 -2
- data/docs/CONSTANTS_REFERENCE.md +5 -4
- data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
- data/docs/LIVE_ORDER_UPDATES.md +5 -10
- data/docs/RAILS_INTEGRATION.md +1 -1
- data/docs/RELEASE_GUIDE.md +13 -2
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
- data/docs/WEBSOCKET_INTEGRATION.md +13 -20
- data/docs/WEBSOCKET_PROTOCOL.md +7 -3
- data/lib/DhanHQ/agent/key_coercion.rb +36 -0
- data/lib/DhanHQ/agent/tool.rb +37 -0
- data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
- data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
- data/lib/DhanHQ/agent/tool_registry.rb +17 -261
- data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
- data/lib/DhanHQ/client.rb +105 -30
- data/lib/DhanHQ/concerns/order_audit.rb +35 -4
- data/lib/DhanHQ/configuration.rb +70 -1
- data/lib/DhanHQ/constants.rb +101 -0
- data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
- data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
- data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
- data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
- data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
- data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
- data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
- data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
- data/lib/DhanHQ/dry_run/ledger.rb +71 -0
- data/lib/DhanHQ/dry_run/simulator.rb +140 -0
- data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
- data/lib/DhanHQ/models/alert_order.rb +6 -3
- data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
- data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
- data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
- data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
- data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
- data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
- data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
- data/lib/DhanHQ/models/margin.rb +5 -1
- data/lib/DhanHQ/models/multi_order.rb +130 -0
- data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
- data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
- data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
- data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
- data/lib/DhanHQ/resources/multi_orders.rb +58 -0
- data/lib/DhanHQ/risk/pipeline.rb +0 -2
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/write_paths.rb +57 -0
- data/lib/DhanHQ/ws/client.rb +117 -2
- data/lib/DhanHQ/ws/connection.rb +40 -11
- data/lib/DhanHQ/ws/sub_state.rb +14 -0
- data/lib/dhan_hq.rb +4 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
- data/skills/dhanhq-ruby/SKILL.md +2 -1
- data/skills/dhanhq-ruby/examples/order_management.rb +7 -0
- data/skills/dhanhq-ruby/references/common-workflows.md +8 -8
- data/skills/dhanhq-ruby/references/instruments.md +7 -1
- data/skills/dhanhq-ruby/references/orders.md +2 -0
- metadata +30 -10
- data/.rspec +0 -3
- data/.rubocop.yml +0 -50
- data/.rubocop_todo.yml +0 -217
- data/Rakefile +0 -14
- data/TAGS +0 -10
- data/core +0 -0
- data/diagram.html +0 -184
- data/watchlist.csv +0 -3
data/lib/DhanHQ/client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "active_support/core_ext/hash/indifferent_access"
|
|
6
7
|
require_relative "errors"
|
|
7
8
|
require_relative "rate_limiter"
|
|
@@ -24,31 +25,57 @@ module DhanHQ
|
|
|
24
25
|
include DhanHQ::RequestHelper
|
|
25
26
|
include DhanHQ::ResponseHelper
|
|
26
27
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
# Transient failures raised by this gem, worth retrying as-is.
|
|
29
|
+
RETRYABLE_SDK_ERRORS = [
|
|
30
|
+
DhanHQ::RateLimitError,
|
|
31
|
+
DhanHQ::InternalServerError,
|
|
32
|
+
DhanHQ::NetworkError
|
|
33
|
+
].freeze
|
|
34
|
+
private_constant :RETRYABLE_SDK_ERRORS
|
|
35
|
+
|
|
36
|
+
# Transport failures raised by Faraday. Also worth retrying, but they are not part
|
|
37
|
+
# of this gem's error hierarchy, so a caller rescuing DhanHQ::Error would miss
|
|
38
|
+
# them — they are translated on the way out. See {#exhausted_error}.
|
|
39
|
+
RETRYABLE_TRANSPORT_ERRORS = [
|
|
40
|
+
Faraday::TimeoutError,
|
|
41
|
+
Faraday::ConnectionFailed
|
|
42
|
+
].freeze
|
|
43
|
+
private_constant :RETRYABLE_TRANSPORT_ERRORS
|
|
31
44
|
|
|
32
45
|
attr_reader :token_manager
|
|
33
46
|
|
|
34
|
-
# Initializes a new DhanHQ Client
|
|
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.
|
|
35
51
|
#
|
|
36
52
|
# @example Create a new client:
|
|
37
53
|
# client = DhanHQ::Client.new(api_type: :order_api)
|
|
38
54
|
#
|
|
39
55
|
# @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
|
|
40
56
|
# @return [DhanHQ::Client] A new client instance.
|
|
41
|
-
# @raise [DhanHQ::Error] If
|
|
57
|
+
# @raise [DhanHQ::Error] If the rate limiter cannot be resolved for this API type.
|
|
42
58
|
def initialize(api_type:)
|
|
43
59
|
DhanHQ.ensure_configuration!
|
|
44
|
-
#
|
|
60
|
+
# Shared per API type, so separate clients coordinate against one limit.
|
|
45
61
|
@rate_limiter = RateLimiter.for(api_type)
|
|
46
62
|
|
|
47
63
|
raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
|
|
64
|
+
end
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
# 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.
|
|
71
|
+
#
|
|
72
|
+
# @return [Faraday::Connection]
|
|
73
|
+
def connection
|
|
74
|
+
current_url = DhanHQ.configuration.base_url
|
|
75
|
+
return @connection if @connection && @last_base_url == current_url
|
|
76
|
+
|
|
77
|
+
@last_base_url = current_url
|
|
78
|
+
@connection = build_connection(current_url)
|
|
52
79
|
end
|
|
53
80
|
|
|
54
81
|
# Sends an HTTP request to the API with automatic retry for transient errors.
|
|
@@ -60,12 +87,18 @@ module DhanHQ
|
|
|
60
87
|
# @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] Parsed JSON response.
|
|
61
88
|
# @raise [DhanHQ::Error] If an HTTP error occurs.
|
|
62
89
|
def request(method, path, payload, retries: 3)
|
|
90
|
+
payload = with_correlation_id(method, path, payload)
|
|
91
|
+
return dry_run.response_for(method, path, payload) if dry_run.simulates?(method, path)
|
|
92
|
+
|
|
63
93
|
@token_manager&.ensure_valid_token!
|
|
64
94
|
@rate_limiter.throttle!
|
|
65
|
-
|
|
95
|
+
|
|
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
|
+
effective_retries = retryable_write?(method, path) ? retries : 0
|
|
66
99
|
|
|
67
100
|
with_auth_retry do
|
|
68
|
-
with_transient_retry(retries:
|
|
101
|
+
with_transient_retry(retries: effective_retries) do
|
|
69
102
|
response = connection.send(method, path) do |req|
|
|
70
103
|
req.headers.merge!(build_headers(path))
|
|
71
104
|
prepare_payload(req, payload, method, path)
|
|
@@ -91,23 +124,13 @@ module DhanHQ
|
|
|
91
124
|
attempt = 0
|
|
92
125
|
begin
|
|
93
126
|
yield
|
|
94
|
-
rescue
|
|
95
|
-
attempt += 1
|
|
96
|
-
raise if attempt > retries
|
|
97
|
-
|
|
98
|
-
backoff = calculate_backoff(attempt)
|
|
99
|
-
DhanHQ.logger&.warn(
|
|
100
|
-
"[DhanHQ::Client] Transient error (#{e.class}), retrying in #{backoff}s (attempt #{attempt}/#{retries})"
|
|
101
|
-
)
|
|
102
|
-
sleep(backoff)
|
|
103
|
-
retry
|
|
104
|
-
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
|
|
127
|
+
rescue *RETRYABLE_SDK_ERRORS, *RETRYABLE_TRANSPORT_ERRORS => e
|
|
105
128
|
attempt += 1
|
|
106
|
-
raise
|
|
129
|
+
raise exhausted_error(e, retries) if attempt > retries
|
|
107
130
|
|
|
108
131
|
backoff = calculate_backoff(attempt)
|
|
109
132
|
DhanHQ.logger&.warn(
|
|
110
|
-
"[DhanHQ::Client]
|
|
133
|
+
"[DhanHQ::Client] Transient failure (#{e.class}), retrying in #{backoff}s (attempt #{attempt}/#{retries})"
|
|
111
134
|
)
|
|
112
135
|
sleep(backoff)
|
|
113
136
|
retry
|
|
@@ -197,12 +220,64 @@ module DhanHQ
|
|
|
197
220
|
|
|
198
221
|
private
|
|
199
222
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
223
|
+
# Simulator that answers state-changing requests locally when dry run is on.
|
|
224
|
+
#
|
|
225
|
+
# @return [DhanHQ::DryRun::Simulator]
|
|
226
|
+
def dry_run
|
|
227
|
+
@dry_run ||= DhanHQ::DryRun::Simulator.new
|
|
228
|
+
end
|
|
203
229
|
|
|
204
|
-
|
|
205
|
-
|
|
230
|
+
# A write may be auto-retried only when the user has explicitly opted in.
|
|
231
|
+
def retryable_write?(method, path)
|
|
232
|
+
return true unless WritePaths.mutating?(method, path)
|
|
233
|
+
|
|
234
|
+
DhanHQ.configuration&.retry_non_idempotent_writes? || false
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Fills in a +correlationId+ on order placements that lack one, so a caller who
|
|
238
|
+
# hits a timeout can still discover whether the order reached the exchange via
|
|
239
|
+
# GET /v2/orders/external/{correlation-id}.
|
|
240
|
+
def with_correlation_id(method, path, payload)
|
|
241
|
+
return payload unless WritePaths.order_placement?(method, path)
|
|
242
|
+
return payload unless payload.is_a?(Hash)
|
|
243
|
+
return payload unless DhanHQ.configuration&.auto_correlation_id?
|
|
244
|
+
return payload if correlation_id?(payload)
|
|
245
|
+
|
|
246
|
+
inject_correlation_id(payload)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Keys a caller may already have used for the correlation id, in either casing.
|
|
250
|
+
CORRELATION_ID_KEYS = %i[correlationId correlation_id].flat_map { |key| [key, key.to_s] }.freeze
|
|
251
|
+
private_constant :CORRELATION_ID_KEYS
|
|
252
|
+
|
|
253
|
+
# @return [Boolean] True when the caller supplied their own correlation id.
|
|
254
|
+
def correlation_id?(payload)
|
|
255
|
+
CORRELATION_ID_KEYS.any? { |key| payload.key?(key) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Returns a copy of the payload carrying a generated correlation id, matching the
|
|
259
|
+
# key type the payload already uses.
|
|
260
|
+
def inject_correlation_id(payload)
|
|
261
|
+
correlation_id = "dhq-#{SecureRandom.hex(8)}"
|
|
262
|
+
out = payload.dup
|
|
263
|
+
out.keys.any?(String) ? out["correlationId"] = correlation_id : out[:correlationId] = correlation_id
|
|
264
|
+
out
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The exception to raise once the retries are spent.
|
|
268
|
+
#
|
|
269
|
+
# Faraday's transport errors are translated to {DhanHQ::NetworkError}, so a caller
|
|
270
|
+
# rescuing DhanHQ::Error sees every failure this client can produce. This gem's own
|
|
271
|
+
# errors already belong to that hierarchy and are re-raised unchanged.
|
|
272
|
+
#
|
|
273
|
+
# @param error [StandardError] The failure that exhausted the retries.
|
|
274
|
+
# @param retries [Integer] How many retries were allowed.
|
|
275
|
+
# @return [StandardError] The exception to raise.
|
|
276
|
+
def exhausted_error(error, retries)
|
|
277
|
+
return error unless RETRYABLE_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
278
|
+
|
|
279
|
+
attempts = retries.zero? ? "" : " after #{retries} retries"
|
|
280
|
+
DhanHQ::NetworkError.new("Request failed#{attempts}: #{error.message}")
|
|
206
281
|
end
|
|
207
282
|
|
|
208
283
|
def build_connection(url)
|
|
@@ -50,14 +50,21 @@ module DhanHQ
|
|
|
50
50
|
#
|
|
51
51
|
# Extracts +security_id+ and +exchange_segment+ from the params,
|
|
52
52
|
# resolves the instrument, and calls {DhanHQ::Risk::Pipeline.run!}.
|
|
53
|
-
# If the instrument cannot be resolved the check is skipped
|
|
54
|
-
#
|
|
53
|
+
# If the instrument cannot be resolved the check is skipped rather than
|
|
54
|
+
# blocking the order, since a transient scrip-master lookup failure should not
|
|
55
|
+
# stop a valid trade.
|
|
56
|
+
#
|
|
57
|
+
# Anything other than a lookup failure is logged before being swallowed. This
|
|
58
|
+
# method used to rescue StandardError silently, which is how a wrong-arity call
|
|
59
|
+
# to `Instrument.find` went unnoticed long enough for the risk pipeline to never
|
|
60
|
+
# run for any real order (see CHANGELOG 3.1.0). A skipped check is tolerable; a
|
|
61
|
+
# skipped check nobody can see is not.
|
|
55
62
|
def run_risk_checks!(params)
|
|
56
63
|
security_id = extract_param(params, :securityId, :security_id)
|
|
57
64
|
exchange_segment = extract_param(params, :exchangeSegment, :exchange_segment)
|
|
58
65
|
return unless security_id && exchange_segment
|
|
59
66
|
|
|
60
|
-
instrument =
|
|
67
|
+
instrument = resolve_instrument_for_risk(exchange_segment, security_id)
|
|
61
68
|
return unless instrument
|
|
62
69
|
|
|
63
70
|
DhanHQ::Risk::Pipeline.run!(
|
|
@@ -68,10 +75,34 @@ module DhanHQ
|
|
|
68
75
|
)
|
|
69
76
|
rescue DhanHQ::RiskViolation
|
|
70
77
|
raise
|
|
71
|
-
rescue StandardError
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
log_risk_check_skipped(e, security_id, exchange_segment)
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Resolves the instrument the risk pipeline needs, tolerating a lookup failure.
|
|
84
|
+
#
|
|
85
|
+
# @return [DhanHQ::Models::Instrument, nil]
|
|
86
|
+
def resolve_instrument_for_risk(exchange_segment, security_id)
|
|
87
|
+
DhanHQ::Models::Instrument.find_by_security_id(exchange_segment, security_id)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
log_risk_check_skipped(e, security_id, exchange_segment)
|
|
72
90
|
nil
|
|
73
91
|
end
|
|
74
92
|
|
|
93
|
+
# Records that pre-trade risk checks did not run, and why.
|
|
94
|
+
def log_risk_check_skipped(error, security_id, exchange_segment)
|
|
95
|
+
DhanHQ.logger&.error(
|
|
96
|
+
JSON.generate(
|
|
97
|
+
event: "DHAN_RISK_CHECK_SKIPPED",
|
|
98
|
+
security_id: security_id,
|
|
99
|
+
exchange_segment: exchange_segment,
|
|
100
|
+
error: error.class.name,
|
|
101
|
+
message: error.message
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
75
106
|
# Maps an exchange segment string to a pipeline trade type.
|
|
76
107
|
def trade_type_for(exchange_segment)
|
|
77
108
|
case exchange_segment.to_s
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -41,6 +41,47 @@ module DhanHQ
|
|
|
41
41
|
# Whether to use the sandbox environment.
|
|
42
42
|
attr_accessor :sandbox
|
|
43
43
|
|
|
44
|
+
# When true, state-changing requests (order placement, modification,
|
|
45
|
+
# cancellation, position exits, trading controls) are validated and logged but
|
|
46
|
+
# never sent to the API — the client answers with a simulated response instead.
|
|
47
|
+
#
|
|
48
|
+
# Read-only requests still hit the API, so market data, the option chain,
|
|
49
|
+
# positions and holdings remain live. That makes dry-run usable for a full
|
|
50
|
+
# strategy rehearsal against real prices.
|
|
51
|
+
#
|
|
52
|
+
# Set via +DHAN_DRY_RUN=true+ or in {DhanHQ.configure}.
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
attr_accessor :dry_run
|
|
55
|
+
|
|
56
|
+
# Whether to auto-retry non-idempotent writes (order placement, modification,
|
|
57
|
+
# cancellation) after a transient failure — a 429, a 5xx, or a network timeout.
|
|
58
|
+
#
|
|
59
|
+
# Defaults to +false+, because the DhanHQ API has no idempotency key: a POST
|
|
60
|
+
# /v2/orders that times out may well have reached the exchange, and retrying it
|
|
61
|
+
# can place a second, duplicate order. With this off, the transient error is
|
|
62
|
+
# raised to the caller, who can reconcile using the correlation id (see
|
|
63
|
+
# {#auto_correlation_id} and +DhanHQ::Models::Order.find_by_correlation_id+)
|
|
64
|
+
# before deciding to resubmit.
|
|
65
|
+
#
|
|
66
|
+
# Read requests are always retried regardless of this setting.
|
|
67
|
+
#
|
|
68
|
+
# Set via +DHAN_RETRY_WRITES=true+ or in {DhanHQ.configure}.
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
attr_accessor :retry_non_idempotent_writes
|
|
71
|
+
|
|
72
|
+
# Whether to generate a +correlationId+ for order placements that do not carry
|
|
73
|
+
# one. The correlation id is the only way to answer "did my order actually go
|
|
74
|
+
# through?" after a timeout, via GET /v2/orders/external/{correlation-id}.
|
|
75
|
+
#
|
|
76
|
+
# Defaults to +false+ because it changes the request body, which would break
|
|
77
|
+
# callers matching recorded fixtures on the exact payload. Turn it on to get
|
|
78
|
+
# timeout-reconcilable orders for free. Only fills the field when absent; an
|
|
79
|
+
# explicit correlation id is always preserved.
|
|
80
|
+
#
|
|
81
|
+
# Set via +DHAN_AUTO_CORRELATION_ID=true+ or in {DhanHQ.configure}.
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
attr_accessor :auto_correlation_id
|
|
84
|
+
|
|
44
85
|
# URL for the compact CSV format of instruments.
|
|
45
86
|
# @return [String] URL for compact CSV.
|
|
46
87
|
attr_accessor :compact_csv_url
|
|
@@ -124,6 +165,21 @@ module DhanHQ
|
|
|
124
165
|
@sandbox == true
|
|
125
166
|
end
|
|
126
167
|
|
|
168
|
+
# @return [Boolean] True when state-changing requests should be simulated.
|
|
169
|
+
def dry_run?
|
|
170
|
+
@dry_run == true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [Boolean] True when non-idempotent writes may be auto-retried.
|
|
174
|
+
def retry_non_idempotent_writes?
|
|
175
|
+
@retry_non_idempotent_writes == true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @return [Boolean] True when a correlation id should be generated for orders.
|
|
179
|
+
def auto_correlation_id?
|
|
180
|
+
@auto_correlation_id == true
|
|
181
|
+
end
|
|
182
|
+
|
|
127
183
|
# Initializes a new configuration instance with default values.
|
|
128
184
|
#
|
|
129
185
|
# @example
|
|
@@ -133,7 +189,10 @@ module DhanHQ
|
|
|
133
189
|
def initialize
|
|
134
190
|
@client_id = ENV.fetch("DHAN_CLIENT_ID", nil)
|
|
135
191
|
@access_token = ENV.fetch("DHAN_ACCESS_TOKEN", nil)
|
|
136
|
-
@sandbox =
|
|
192
|
+
@sandbox = env_flag("DHAN_SANDBOX", default: false)
|
|
193
|
+
@dry_run = env_flag("DHAN_DRY_RUN", default: false)
|
|
194
|
+
@retry_non_idempotent_writes = env_flag("DHAN_RETRY_WRITES", default: false)
|
|
195
|
+
@auto_correlation_id = env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
|
|
137
196
|
@base_url = ENV.fetch("DHAN_BASE_URL", nil)
|
|
138
197
|
@ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
|
|
139
198
|
@ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
|
|
@@ -144,5 +203,15 @@ module DhanHQ
|
|
|
144
203
|
@partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
|
|
145
204
|
@partner_secret = ENV.fetch("DHAN_PARTNER_SECRET", nil)
|
|
146
205
|
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# Reads a boolean-ish environment variable, falling back to +default+ when unset.
|
|
210
|
+
def env_flag(name, default:)
|
|
211
|
+
raw = ENV.fetch(name, nil)
|
|
212
|
+
return default if raw.nil? || raw.to_s.strip.empty?
|
|
213
|
+
|
|
214
|
+
raw.to_s.casecmp("true").zero?
|
|
215
|
+
end
|
|
147
216
|
end
|
|
148
217
|
end
|
data/lib/DhanHQ/constants.rb
CHANGED
|
@@ -16,8 +16,14 @@ module DhanHQ
|
|
|
16
16
|
MCX_COMM = "MCX_COMM"
|
|
17
17
|
BSE_CURRENCY = "BSE_CURRENCY"
|
|
18
18
|
BSE_FNO = "BSE_FNO"
|
|
19
|
+
# US / international equities, traded through the Global Stocks APIs
|
|
20
|
+
# (`/v2/globalstocks/*`). Deliberately excluded from {ALL} so it can never
|
|
21
|
+
# satisfy the domestic order contracts.
|
|
22
|
+
INX_EQ = "INX_EQ"
|
|
19
23
|
|
|
20
24
|
ALL = [IDX_I, NSE_EQ, NSE_FNO, NSE_CURRENCY, NSE_COMM, BSE_EQ, MCX_COMM, BSE_CURRENCY, BSE_FNO].freeze
|
|
25
|
+
# Segments served by the Global Stocks APIs.
|
|
26
|
+
GLOBAL_ALL = [INX_EQ].freeze
|
|
21
27
|
# Segments allowed by POST /v2/margincalculator (single and multi).
|
|
22
28
|
MARGIN_CALC_ALL = [NSE_EQ, NSE_FNO, BSE_EQ, BSE_FNO, MCX_COMM].freeze
|
|
23
29
|
# Segments allowed by POST /v2/forever/orders (create).
|
|
@@ -218,6 +224,64 @@ module DhanHQ
|
|
|
218
224
|
].freeze
|
|
219
225
|
end
|
|
220
226
|
|
|
227
|
+
# Enumerations specific to the Global Stocks (US equities) APIs served under
|
|
228
|
+
# `/v2/globalstocks/*`. These orders carry no exchange segment, product type
|
|
229
|
+
# or validity — the domestic {ProductType}/{Validity} enums do not apply.
|
|
230
|
+
#
|
|
231
|
+
# @see https://dhanhq.co/docs/v2/ Global Stocks section
|
|
232
|
+
module GlobalStocks
|
|
233
|
+
# The only exchange segment currently supported by the Global Stocks feed.
|
|
234
|
+
EXCHANGE_SEGMENT = ExchangeSegment::INX_EQ
|
|
235
|
+
# Numeric exchange-segment code used in the binary WebSocket header.
|
|
236
|
+
EXCHANGE_SEGMENT_CODE = 14
|
|
237
|
+
# Maximum instruments accepted per Global Stocks feed subscription frame.
|
|
238
|
+
MAX_INSTRUMENTS_PER_REQUEST = 100
|
|
239
|
+
# Maximum concurrent subscriptions on a single Global Stocks connection.
|
|
240
|
+
MAX_SUBSCRIPTIONS_PER_CONNECTION = 5_000
|
|
241
|
+
# Maximum concurrent Global Stocks feed connections per client.
|
|
242
|
+
MAX_CONNECTIONS_PER_CLIENT = 5
|
|
243
|
+
|
|
244
|
+
# Order types accepted by POST /v2/globalstocks/orders. Adds +AMOUNT+
|
|
245
|
+
# (notional/dollar-value orders) on top of the domestic order types.
|
|
246
|
+
module OrderType
|
|
247
|
+
MARKET = "MARKET"
|
|
248
|
+
LIMIT = "LIMIT"
|
|
249
|
+
STOP_LOSS = "STOP_LOSS"
|
|
250
|
+
STOP_LOSS_MARKET = "STOP_LOSS_MARKET"
|
|
251
|
+
AMOUNT = "AMOUNT"
|
|
252
|
+
|
|
253
|
+
ALL = [MARKET, LIMIT, STOP_LOSS, STOP_LOSS_MARKET, AMOUNT].freeze
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Leg names accepted when modifying a Global Stocks super order.
|
|
257
|
+
module LegName
|
|
258
|
+
ENTRY_LEG = "ENTRY_LEG"
|
|
259
|
+
STOP_LOSS_LEG = "STOP_LOSS_LEG"
|
|
260
|
+
TARGET_LEG = "TARGET_LEG"
|
|
261
|
+
NA = "NA"
|
|
262
|
+
|
|
263
|
+
ALL = [ENTRY_LEG, STOP_LOSS_LEG, TARGET_LEG, NA].freeze
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Values returned by GET /v2/globalstocks/marketstatus.
|
|
267
|
+
module MarketStatus
|
|
268
|
+
OPEN = "open"
|
|
269
|
+
CLOSED = "closed"
|
|
270
|
+
|
|
271
|
+
ALL = [OPEN, CLOSED].freeze
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Message codes on the Global Stocks binary feed.
|
|
275
|
+
module MsgCode
|
|
276
|
+
TRADE = 1
|
|
277
|
+
PREV_CLOSE = 32
|
|
278
|
+
CIRCUIT_LIMIT = 33
|
|
279
|
+
FIFTY_TWO_WEEK = 36
|
|
280
|
+
|
|
281
|
+
ALL = [TRADE, PREV_CLOSE, CIRCUIT_LIMIT, FIFTY_TWO_WEEK].freeze
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
221
285
|
# Comparison types for conditional trigger alerts.
|
|
222
286
|
module ComparisonType
|
|
223
287
|
TECHNICAL_WITH_VALUE = "TECHNICAL_WITH_VALUE"
|
|
@@ -399,6 +463,7 @@ module DhanHQ
|
|
|
399
463
|
WS_ORDER_UPDATE = "wss://api-order-update.dhan.co"
|
|
400
464
|
WS_DEPTH_20 = "wss://depth-api-feed.dhan.co/twentydepth"
|
|
401
465
|
WS_DEPTH_200 = "wss://full-depth-api.dhan.co/twohundreddepth"
|
|
466
|
+
WS_GLOBAL_STOCKS_FEED = "wss://global-stocks-api-feed.dhan.co"
|
|
402
467
|
INSTRUMENT_CSV_COMPACT = "https://images.dhan.co/api-data/api-scrip-master.csv"
|
|
403
468
|
INSTRUMENT_CSV_DETAILED = "https://images.dhan.co/api-data/api-scrip-master-detailed.csv"
|
|
404
469
|
DOCS = "https://dhanhq.co/docs/v2"
|
|
@@ -424,6 +489,9 @@ module DhanHQ
|
|
|
424
489
|
COMPARISON_TYPES = ComparisonType::ALL
|
|
425
490
|
OPERATORS = Operator::ALL
|
|
426
491
|
ALERT_CONDITION_SEGMENTS = ExchangeSegment::ALERT_CONDITION_ALL
|
|
492
|
+
GLOBAL_STOCKS_SEGMENTS = ExchangeSegment::GLOBAL_ALL
|
|
493
|
+
GLOBAL_STOCKS_ORDER_TYPES = GlobalStocks::OrderType::ALL
|
|
494
|
+
GLOBAL_STOCKS_LEG_NAMES = GlobalStocks::LegName::ALL
|
|
427
495
|
ALERT_TIMEFRAMES = %w[DATE ONE_MIN FIVE_MIN FIFTEEN_MIN DAY].freeze
|
|
428
496
|
|
|
429
497
|
# Exchange aliases used when building subscription payloads.
|
|
@@ -475,6 +543,7 @@ module DhanHQ
|
|
|
475
543
|
# Injection is done in the client layer when building the payload.
|
|
476
544
|
PAYLOAD_REQUIRES_DHAN_CLIENT_ID_PREFIXES = %w[
|
|
477
545
|
/alerts/orders
|
|
546
|
+
/v2/alerts
|
|
478
547
|
/v2/orders
|
|
479
548
|
/v2/orders/iceberg
|
|
480
549
|
/v2/orders/twap
|
|
@@ -485,6 +554,38 @@ module DhanHQ
|
|
|
485
554
|
/v2/margincalculator
|
|
486
555
|
/v2/killswitch
|
|
487
556
|
/v2/ip
|
|
557
|
+
/v2/globalstocks
|
|
558
|
+
].freeze
|
|
559
|
+
|
|
560
|
+
# Path prefixes whose non-GET requests change real account state — they place,
|
|
561
|
+
# modify or cancel orders, exit positions, or alter trading controls.
|
|
562
|
+
#
|
|
563
|
+
# Note that a POST is not automatically a mutation on this API: option chain,
|
|
564
|
+
# market feed, historical charts and the margin calculators are all read-only
|
|
565
|
+
# POSTs. Dry-run mode and non-idempotent retry protection consult this list so
|
|
566
|
+
# they act on real writes only and leave data fetches alone.
|
|
567
|
+
MUTATING_PATH_PREFIXES = %w[
|
|
568
|
+
/v2/orders
|
|
569
|
+
/v2/super/orders
|
|
570
|
+
/v2/forever
|
|
571
|
+
/v2/alerts
|
|
572
|
+
/v2/globalstocks/orders
|
|
573
|
+
/v2/positions
|
|
574
|
+
/v2/killswitch
|
|
575
|
+
/v2/pnlExit
|
|
576
|
+
/v2/edis
|
|
577
|
+
/v2/ip
|
|
578
|
+
].freeze
|
|
579
|
+
|
|
580
|
+
# Subset of {MUTATING_PATH_PREFIXES} that create orders. A retried POST to one
|
|
581
|
+
# of these can produce a duplicate order, and dry-run mode answers these with a
|
|
582
|
+
# simulated order id so caller code paths still complete.
|
|
583
|
+
ORDER_PLACEMENT_PATH_PREFIXES = %w[
|
|
584
|
+
/v2/orders
|
|
585
|
+
/v2/super/orders
|
|
586
|
+
/v2/forever
|
|
587
|
+
/v2/alerts
|
|
588
|
+
/v2/globalstocks/orders
|
|
488
589
|
].freeze
|
|
489
590
|
|
|
490
591
|
# Mapping of exchange and segment combinations to canonical exchange segment names.
|
|
@@ -71,8 +71,7 @@ module DhanHQ
|
|
|
71
71
|
from_date = Date.parse(values[:from_date])
|
|
72
72
|
to_date = Date.parse(values[:to_date])
|
|
73
73
|
|
|
74
|
-
key.failure("from_date must be before to_date") if from_date
|
|
75
|
-
key.failure("from_date must be a valid trading date (no weekend dates)") unless trading_day?(from_date)
|
|
74
|
+
key.failure("from_date must be before or equal to to_date") if from_date > to_date
|
|
76
75
|
|
|
77
76
|
# Check if date range is not too large (max 31 days; to_date is non-inclusive)
|
|
78
77
|
key.failure("date range cannot exceed 31 days") if (to_date - from_date).to_i > 31
|
|
@@ -101,13 +100,6 @@ module DhanHQ
|
|
|
101
100
|
false
|
|
102
101
|
end
|
|
103
102
|
end
|
|
104
|
-
|
|
105
|
-
def trading_day?(date)
|
|
106
|
-
return false unless date.is_a?(Date)
|
|
107
|
-
|
|
108
|
-
# Sunday = 0, Saturday = 6; trading days are Monday (1) through Friday (5)
|
|
109
|
-
(1..5).cover?(date.wday)
|
|
110
|
-
end
|
|
111
103
|
end
|
|
112
104
|
end
|
|
113
105
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_contract"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validation contract shared by POST /v2/globalstocks/transEstimate
|
|
8
|
+
# (order charge estimate) and POST /v2/globalstocks/margincalculator.
|
|
9
|
+
#
|
|
10
|
+
# Both endpoints accept the same +InxEstimatorRequest+ body. The API documents
|
|
11
|
+
# +price+ and +quantity+ as strings on the wire; this contract validates them
|
|
12
|
+
# as numbers and the resource stringifies them before sending.
|
|
13
|
+
class GlobalStocksEstimatorContract < BaseContract
|
|
14
|
+
TRANSACTION_TYPES = DhanHQ::Constants::TransactionType::ALL
|
|
15
|
+
|
|
16
|
+
params do
|
|
17
|
+
required(:security_id).filled(:string, max_size?: 50)
|
|
18
|
+
required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
|
|
19
|
+
required(:price).filled(:float, gteq?: 0)
|
|
20
|
+
required(:quantity).filled(:float, gt?: 0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
rule(:price) do
|
|
24
|
+
key.failure("must be a finite number") if value.is_a?(Float) && (value.nan? || value.infinite?)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "global_stocks_order_contract"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validation contract for PUT /v2/globalstocks/orders/{order-id}.
|
|
8
|
+
class GlobalStocksModifyOrderContract < GlobalStocksOrderContract
|
|
9
|
+
params do
|
|
10
|
+
required(:order_id).filled(:string)
|
|
11
|
+
required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
|
|
12
|
+
required(:order_type).filled(:string, included_in?: ORDER_TYPES)
|
|
13
|
+
required(:security_id).filled(:string, max_size?: 50)
|
|
14
|
+
|
|
15
|
+
optional(:quantity).maybe(:float, gt?: 0)
|
|
16
|
+
optional(:price).maybe(:float, gteq?: 0)
|
|
17
|
+
optional(:leg_name).maybe(:string, included_in?: LEG_NAMES)
|
|
18
|
+
optional(:dhan_client_id).maybe(:string)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rule(:order_type, :price) do
|
|
22
|
+
key(:price).failure("must be greater than 0 for LIMIT orders") if PRICED_ORDER_TYPES.include?(values[:order_type]) && !values[:price]&.positive?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-validation"
|
|
4
|
+
require_relative "base_contract"
|
|
5
|
+
|
|
6
|
+
module DhanHQ
|
|
7
|
+
module Contracts
|
|
8
|
+
# Base contract shared by the Global Stocks (US equities) order contracts.
|
|
9
|
+
#
|
|
10
|
+
# Global Stocks orders differ from domestic orders in three ways that make the
|
|
11
|
+
# domestic {OrderContract} unusable here:
|
|
12
|
+
#
|
|
13
|
+
# 1. There is no +exchange_segment+, +product_type+ or +validity+ field.
|
|
14
|
+
# 2. +quantity+ is a float — fractional shares are supported.
|
|
15
|
+
# 3. +order_type+ adds +AMOUNT+, a notional (dollar-value) order which carries
|
|
16
|
+
# an +amount+ instead of a +quantity+.
|
|
17
|
+
#
|
|
18
|
+
# @see https://dhanhq.co/docs/v2/ Global Stocks section
|
|
19
|
+
class GlobalStocksOrderContract < BaseContract
|
|
20
|
+
TRANSACTION_TYPES = DhanHQ::Constants::TransactionType::ALL
|
|
21
|
+
ORDER_TYPES = DhanHQ::Constants::GlobalStocks::OrderType::ALL
|
|
22
|
+
LEG_NAMES = DhanHQ::Constants::GlobalStocks::LegName::ALL
|
|
23
|
+
|
|
24
|
+
# Order types that require a limit price.
|
|
25
|
+
PRICED_ORDER_TYPES = [DhanHQ::Constants::GlobalStocks::OrderType::LIMIT].freeze
|
|
26
|
+
# Order types that require a trigger price.
|
|
27
|
+
TRIGGERED_ORDER_TYPES = [
|
|
28
|
+
DhanHQ::Constants::GlobalStocks::OrderType::STOP_LOSS,
|
|
29
|
+
DhanHQ::Constants::GlobalStocks::OrderType::STOP_LOSS_MARKET
|
|
30
|
+
].freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|