DhanHQ 3.1.0 → 3.2.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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +47 -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/client.rb +58 -2
  22. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  23. data/lib/DhanHQ/configuration.rb +70 -1
  24. data/lib/DhanHQ/constants.rb +101 -0
  25. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  26. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  27. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  28. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  29. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  30. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  31. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  32. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  33. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  34. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  35. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  36. data/lib/DhanHQ/models/alert_order.rb +5 -2
  37. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  38. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  39. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  40. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  41. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  42. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  43. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  44. data/lib/DhanHQ/models/margin.rb +5 -1
  45. data/lib/DhanHQ/models/multi_order.rb +130 -0
  46. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  47. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  48. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  49. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  50. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  51. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  52. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  53. data/lib/DhanHQ/version.rb +1 -1
  54. data/lib/DhanHQ/write_paths.rb +57 -0
  55. data/lib/DhanHQ/ws/client.rb +117 -2
  56. data/lib/DhanHQ/ws/connection.rb +40 -11
  57. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  58. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  59. metadata +30 -40
  60. data/.rspec +0 -3
  61. data/.rubocop.yml +0 -50
  62. data/.rubocop_todo.yml +0 -217
  63. data/AGENTS.md +0 -23
  64. data/CODE_OF_CONDUCT.md +0 -132
  65. data/Rakefile +0 -14
  66. data/TAGS +0 -10
  67. data/core +0 -0
  68. data/diagram.html +0 -184
  69. data/skills/dhanhq-ruby/SKILL.md +0 -207
  70. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +0 -54
  71. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +0 -65
  72. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +0 -89
  73. data/skills/dhanhq-ruby/examples/iron_condor.rb +0 -137
  74. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +0 -43
  75. data/skills/dhanhq-ruby/examples/margin_check.rb +0 -42
  76. data/skills/dhanhq-ruby/examples/order_management.rb +0 -105
  77. data/skills/dhanhq-ruby/examples/place_equity_order.rb +0 -36
  78. data/skills/dhanhq-ruby/examples/place_fno_order.rb +0 -76
  79. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +0 -74
  80. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +0 -57
  81. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +0 -65
  82. data/skills/dhanhq-ruby/references/common-workflows.md +0 -76
  83. data/skills/dhanhq-ruby/references/error-codes.md +0 -50
  84. data/skills/dhanhq-ruby/references/funds.md +0 -67
  85. data/skills/dhanhq-ruby/references/instruments.md +0 -85
  86. data/skills/dhanhq-ruby/references/live-feed.md +0 -83
  87. data/skills/dhanhq-ruby/references/market-data.md +0 -119
  88. data/skills/dhanhq-ruby/references/option-chain.md +0 -71
  89. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +0 -76
  90. data/skills/dhanhq-ruby/references/orders.md +0 -201
  91. data/skills/dhanhq-ruby/references/portfolio.md +0 -93
  92. data/skills/dhanhq-ruby/references/scanx-data.md +0 -62
  93. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +0 -323
  94. data/skills/dhanhq-ruby/scripts/resolve_security.rb +0 -168
  95. data/skills/dhanhq-ruby/scripts/trade_logger.rb +0 -131
  96. data/skills/dhanhq-ruby/scripts/validate_order.rb +0 -169
  97. data/watchlist.csv +0 -3
@@ -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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "global_stocks_order_contract"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validation contract for POST /v2/globalstocks/orders.
8
+ #
9
+ # @example
10
+ # DhanHQ::Contracts::GlobalStocksPlaceOrderContract.new.call(
11
+ # transaction_type: "BUY",
12
+ # order_type: "LIMIT",
13
+ # security_id: "AAPL",
14
+ # quantity: 1.0,
15
+ # price: 180.0
16
+ # )
17
+ class GlobalStocksPlaceOrderContract < GlobalStocksOrderContract
18
+ params do
19
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
20
+ required(:order_type).filled(:string, included_in?: ORDER_TYPES)
21
+ required(:security_id).filled(:string, max_size?: 50)
22
+
23
+ optional(:correlation_id).maybe(:string, max_size?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
24
+ optional(:quantity).maybe(:float, gt?: 0)
25
+ optional(:price).maybe(:float, gteq?: 0)
26
+ optional(:trigger_price).maybe(:float, gt?: 0)
27
+ optional(:stop_loss_price).maybe(:float, gt?: 0)
28
+ optional(:target_price).maybe(:float, gt?: 0)
29
+ optional(:amount).maybe(:float, gt?: 0)
30
+ optional(:after_market_order).maybe(:bool)
31
+ optional(:dhan_client_id).maybe(:string)
32
+ end
33
+
34
+ # AMOUNT orders are notional: they carry `amount` instead of `quantity`.
35
+ # Every other order type needs a quantity.
36
+ rule(:order_type, :quantity, :amount) do
37
+ if values[:order_type] == DhanHQ::Constants::GlobalStocks::OrderType::AMOUNT
38
+ key(:amount).failure("must be present for AMOUNT orders") unless values[:amount]
39
+ elsif !values[:quantity]
40
+ key(:quantity).failure("must be present unless order_type is AMOUNT")
41
+ end
42
+ end
43
+
44
+ rule(:order_type, :price) do
45
+ price = values[:price]
46
+
47
+ key(:price).failure("must be greater than 0 for LIMIT orders") if PRICED_ORDER_TYPES.include?(values[:order_type]) && !price&.positive?
48
+ key(:price).failure("must be a finite number") if price.is_a?(Float) && (price.nan? || price.infinite?)
49
+ end
50
+
51
+ rule(:order_type, :trigger_price) do
52
+ key(:trigger_price).failure("must be present for STOP_LOSS orders") if TRIGGERED_ORDER_TYPES.include?(values[:order_type]) && !values[:trigger_price]
53
+ end
54
+
55
+ # Mirrors the domestic super-order relationship checks: a BUY exits above
56
+ # entry and stops out below it, and vice versa for a SELL.
57
+ rule(:transaction_type, :price, :target_price, :stop_loss_price) do
58
+ price = values[:price]
59
+ next unless price&.positive?
60
+
61
+ target = values[:target_price]
62
+ stop = values[:stop_loss_price]
63
+
64
+ case values[:transaction_type]
65
+ when DhanHQ::Constants::TransactionType::BUY
66
+ key(:target_price).failure("must be greater than price for BUY orders") if target && target <= price
67
+ key(:stop_loss_price).failure("must be less than price for BUY orders") if stop && stop >= price
68
+ when DhanHQ::Constants::TransactionType::SELL
69
+ key(:target_price).failure("must be less than price for SELL orders") if target && target >= price
70
+ key(:stop_loss_price).failure("must be greater than price for SELL orders") if stop && stop <= price
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -18,36 +18,16 @@ module DhanHQ
18
18
  optional(:oi).maybe(:bool)
19
19
  end
20
20
 
21
- rule(:from_date) do
22
- next unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
23
-
24
- # Only validate weekend for pure date strings (YYYY-MM-DD)
25
- if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
26
- d = Date.parse(value)
27
- key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(d)
28
- end
29
- rescue Date::Error
30
- key.failure("invalid date format")
31
- end
32
-
33
21
  rule(:from_date, :to_date) do
34
22
  next unless values[:from_date].match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/) &&
35
23
  values[:to_date].match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
36
24
 
37
25
  from_date = DateTime.parse(values[:from_date])
38
26
  to_date = DateTime.parse(values[:to_date])
39
- key.failure("from_date must be before to_date") if from_date >= to_date
27
+ key.failure("from_date must be before or equal to to_date") if from_date > to_date
40
28
  rescue Date::Error
41
29
  key.failure("invalid date format")
42
30
  end
43
-
44
- private
45
-
46
- def trading_day?(date)
47
- return false unless date.is_a?(Date)
48
-
49
- (1..5).cover?(date.wday)
50
- end
51
31
  end
52
32
  end
53
33
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-validation"
4
+ require_relative "base_contract"
5
+
6
+ module DhanHQ
7
+ module Contracts
8
+ # Validation contract for POST /v2/alerts/multi/orders (basket orders).
9
+ #
10
+ # A multi order places up to {MAX_ORDERS} unconditional orders in a single
11
+ # request. Each leg carries a +sequence+ that identifies it in the response.
12
+ class MultiOrderContract < BaseContract
13
+ # Maximum legs the API accepts in one basket.
14
+ MAX_ORDERS = 15
15
+
16
+ TRANSACTION_TYPES = DhanHQ::Constants::TransactionType::ALL
17
+ EXCHANGE_SEGMENTS = DhanHQ::Constants::ExchangeSegment::ALL
18
+ PRODUCT_TYPES = DhanHQ::Constants::ProductType::MARGIN_CALC_ALL
19
+ ORDER_TYPES = DhanHQ::Constants::OrderType::ALL
20
+ VALIDITY_TYPES = DhanHQ::Constants::Validity::ALL
21
+ AMO_TIMES = DhanHQ::Constants::AmoTime::ALL
22
+
23
+ params do
24
+ optional(:dhan_client_id).maybe(:string)
25
+
26
+ required(:orders).array(:hash) do
27
+ required(:sequence).filled(:string)
28
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
29
+ required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
30
+
31
+ optional(:product_type).maybe(:string, included_in?: PRODUCT_TYPES)
32
+ optional(:order_type).maybe(:string, included_in?: ORDER_TYPES)
33
+ optional(:validity).maybe(:string, included_in?: VALIDITY_TYPES)
34
+ optional(:security_id).maybe(:string)
35
+ optional(:quantity).maybe(:integer, gt?: 0)
36
+ optional(:price).maybe(:float, gt?: 0)
37
+ optional(:trigger_price).maybe(:float, gt?: 0)
38
+ optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
39
+ optional(:correlation_id).maybe(:string, max_size?: 30)
40
+ optional(:after_market_order).maybe(:bool)
41
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMES)
42
+ end
43
+ end
44
+
45
+ rule(:orders) do
46
+ key.failure("must contain at least one order") if value.empty?
47
+ key.failure("cannot contain more than #{MAX_ORDERS} orders") if value.size > MAX_ORDERS
48
+
49
+ sequences = value.map { |order| order[:sequence] }
50
+ key.failure("sequence values must be unique") if sequences.uniq.size != sequences.size
51
+ end
52
+
53
+ # Order types that require a trigger price on each leg.
54
+ TRIGGERED_ORDER_TYPES = [
55
+ DhanHQ::Constants::OrderType::STOP_LOSS,
56
+ DhanHQ::Constants::OrderType::STOP_LOSS_MARKET
57
+ ].freeze
58
+
59
+ # Per-leg price/trigger consistency, mirroring the single-order contract.
60
+ rule(:orders) do
61
+ value.each_with_index do |order, index|
62
+ type = order[:order_type]
63
+
64
+ key([:orders, index, :price]).failure("must be present for LIMIT orders") if type == DhanHQ::Constants::OrderType::LIMIT && !order[:price]
65
+ key([:orders, index, :price]).failure("must not be provided for MARKET orders") if type == DhanHQ::Constants::OrderType::MARKET && order[:price]
66
+
67
+ next unless TRIGGERED_ORDER_TYPES.include?(type) && !order[:trigger_price]
68
+
69
+ key([:orders, index, :trigger_price]).failure("must be present for STOP_LOSS orders")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -14,9 +14,6 @@ module DhanHQ
14
14
 
15
15
  rule(:from_date) do
16
16
  key.failure("must be in YYYY-MM-DD format (e.g., 2024-01-15)") unless valid_date_format?(value)
17
- next unless valid_date_format?(value)
18
-
19
- key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(Date.parse(value))
20
17
  end
21
18
 
22
19
  rule(:to_date) do
@@ -28,7 +25,7 @@ module DhanHQ
28
25
  to = values[:to_date]
29
26
  next unless valid_date_format?(from) && valid_date_format?(to)
30
27
 
31
- key.failure("from_date must be before to_date") if Date.parse(from) >= Date.parse(to)
28
+ key.failure("from_date must be before or equal to to_date") if Date.parse(from) > Date.parse(to)
32
29
  rescue Date::Error
33
30
  key.failure("invalid date format")
34
31
  end
@@ -43,10 +40,6 @@ module DhanHQ
43
40
  rescue Date::Error
44
41
  false
45
42
  end
46
-
47
- def trading_day?(date)
48
- date.is_a?(Date) && (1..5).cover?(date.wday)
49
- end
50
43
  end
51
44
  end
52
45
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module DhanHQ
6
+ module DryRun
7
+ # Remembers the orders a dry run pretended to place, so a follow-up read for one
8
+ # of those simulated ids can be answered locally.
9
+ #
10
+ # This exists because the high-level models re-fetch after placing:
11
+ # `Models::Order.place` and `GlobalStocks::Order.place` both call `find(order_id)`
12
+ # to return the complete record. Without a ledger that GET would leave the process
13
+ # carrying a `DRYRUN-…` id to the live API, which is both a real network call and a
14
+ # guaranteed miss — so dry run would only have worked for direct
15
+ # {DhanHQ::Client#request} calls, not the API people actually use.
16
+ #
17
+ # Entries are per-process and capped; a long rehearsal evicts its oldest records
18
+ # rather than growing without bound.
19
+ class Ledger
20
+ # Maximum simulated orders retained before the oldest are evicted.
21
+ MAX_ENTRIES = 500
22
+
23
+ def initialize(max_entries: MAX_ENTRIES)
24
+ @max_entries = max_entries
25
+ @records = {}
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ # Process-wide ledger used by {DhanHQ::DryRun::Simulator}.
30
+ #
31
+ # @return [Ledger]
32
+ def self.instance
33
+ @instance ||= new
34
+ end
35
+
36
+ # Records a simulated order.
37
+ #
38
+ # @param order_id [String] The simulated order id
39
+ # @param record [Hash] The response to replay for reads of that id
40
+ # @return [Hash] The stored record
41
+ def record(order_id, record)
42
+ @mutex.synchronize do
43
+ @records.delete(order_id)
44
+ @records[order_id] = record
45
+ @records.shift while @records.size > @max_entries
46
+ record
47
+ end
48
+ end
49
+
50
+ # Fetches a previously simulated order.
51
+ #
52
+ # @param order_id [String]
53
+ # @return [Hash, nil] nil when the id was never simulated in this process
54
+ def fetch(order_id)
55
+ @mutex.synchronize { @records[order_id] }
56
+ end
57
+
58
+ # @return [Integer] Number of retained records
59
+ def size
60
+ @mutex.synchronize { @records.size }
61
+ end
62
+
63
+ # Drops every retained record. Intended for test isolation.
64
+ #
65
+ # @return [void]
66
+ def clear
67
+ @mutex.synchronize { @records.clear }
68
+ end
69
+ end
70
+ end
71
+ end