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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +73 -0
  4. data/README.md +241 -3
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +5 -4
  8. data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
  9. data/docs/LIVE_ORDER_UPDATES.md +5 -10
  10. data/docs/RAILS_INTEGRATION.md +1 -1
  11. data/docs/RELEASE_GUIDE.md +13 -2
  12. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
  13. data/docs/WEBSOCKET_INTEGRATION.md +13 -20
  14. data/docs/WEBSOCKET_PROTOCOL.md +7 -3
  15. data/lib/DhanHQ/agent/key_coercion.rb +36 -0
  16. data/lib/DhanHQ/agent/tool.rb +37 -0
  17. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  18. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  19. data/lib/DhanHQ/agent/tool_registry.rb +17 -261
  20. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  21. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  22. data/lib/DhanHQ/client.rb +105 -30
  23. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +101 -0
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  28. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  29. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  30. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  31. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  32. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  33. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  34. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  35. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  36. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  37. data/lib/DhanHQ/models/alert_order.rb +6 -3
  38. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  39. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  40. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  41. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  42. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  43. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  44. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  45. data/lib/DhanHQ/models/margin.rb +5 -1
  46. data/lib/DhanHQ/models/multi_order.rb +130 -0
  47. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  48. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  49. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  50. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  51. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  52. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  53. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  55. data/lib/DhanHQ/version.rb +1 -1
  56. data/lib/DhanHQ/write_paths.rb +57 -0
  57. data/lib/DhanHQ/ws/client.rb +117 -2
  58. data/lib/DhanHQ/ws/connection.rb +40 -11
  59. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  60. data/lib/dhan_hq.rb +4 -0
  61. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  62. data/skills/dhanhq-ruby/SKILL.md +2 -1
  63. data/skills/dhanhq-ruby/examples/order_management.rb +7 -0
  64. data/skills/dhanhq-ruby/references/common-workflows.md +8 -8
  65. data/skills/dhanhq-ruby/references/instruments.md +7 -1
  66. data/skills/dhanhq-ruby/references/orders.md +2 -0
  67. metadata +30 -10
  68. data/.rspec +0 -3
  69. data/.rubocop.yml +0 -50
  70. data/.rubocop_todo.yml +0 -217
  71. data/Rakefile +0 -14
  72. data/TAGS +0 -10
  73. data/core +0 -0
  74. data/diagram.html +0 -184
  75. 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
- # The Faraday connection object used for HTTP requests.
28
- #
29
- # @return [Faraday::Connection] The connection instance used for API requests.
30
- attr_reader :connection
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 instance with a Faraday connection.
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 configuration is invalid or rate limiter initialization fails
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
- # Use shared rate limiter instance per API type to ensure proper coordination
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
- # Store initial URL to detect changes
50
- @last_base_url = DhanHQ.configuration.base_url
51
- @connection = build_connection(@last_base_url)
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
- refresh_connection!
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: retries) do
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 DhanHQ::RateLimitError, DhanHQ::InternalServerError, DhanHQ::NetworkError => e
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 DhanHQ::NetworkError, "Request failed after #{retries} retries: #{e.message}" if attempt > retries
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] Network error (#{e.class}), retrying in #{backoff}s (attempt #{attempt}/#{retries})"
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
- def refresh_connection!
201
- current_url = DhanHQ.configuration.base_url
202
- return if @last_base_url == current_url
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
- @last_base_url = current_url
205
- @connection = build_connection(current_url)
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 silently
54
- # rather than blocking the order.
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 = DhanHQ::Models::Instrument.find_by_security_id(exchange_segment, security_id)
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
@@ -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 = ENV.fetch("DHAN_SANDBOX", "false").to_s.casecmp("true").zero?
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
@@ -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 >= to_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