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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module GlobalStocks
6
+ ##
7
+ # Charge estimate for a prospective Global Stocks order.
8
+ #
9
+ # @example Estimate the cost of a trade before placing it
10
+ # est = DhanHQ::Models::GlobalStocks::OrderEstimate.calculate(
11
+ # security_id: "AAPL", transaction_type: "BUY", price: 180.0, quantity: 1
12
+ # )
13
+ # puts "Charges: $#{est.total_charges}"
14
+ #
15
+ class OrderEstimate < BaseModel
16
+ HTTP_PATH = "/v2/globalstocks/transEstimate"
17
+
18
+ attributes :brokerage, :order_charges, :exchange_charges, :turn_over_fee,
19
+ :gst_charges, :other_charges
20
+
21
+ # Returns a concise prompt-friendly summary of the charges.
22
+ def to_prompt
23
+ parts = []
24
+ parts << "brokerage=$#{brokerage}" if brokerage
25
+ parts << "exchange=$#{exchange_charges}" if exchange_charges
26
+ parts << "gst=$#{gst_charges}" if gst_charges
27
+ parts << "total=$#{total_charges}"
28
+ parts.join(", ")
29
+ end
30
+
31
+ # Sum of every charge component.
32
+ #
33
+ # The API also returns +order_charges+ as its own total; this method adds up
34
+ # the components so the figure stays correct even when the API omits it.
35
+ #
36
+ # @return [Float]
37
+ def total_charges
38
+ [brokerage, exchange_charges, turn_over_fee, gst_charges, other_charges]
39
+ .sum(&:to_f)
40
+ .round(4)
41
+ end
42
+
43
+ class << self
44
+ # @return [DhanHQ::Resources::GlobalStocks::MarginCalculator]
45
+ def resource
46
+ @resource ||= DhanHQ::Resources::GlobalStocks::MarginCalculator.new
47
+ end
48
+
49
+ # Estimates the charges applicable to a Global Stocks order.
50
+ #
51
+ # @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
52
+ # @return [OrderEstimate]
53
+ def calculate(params)
54
+ new(resource.estimate(params), skip_validation: true)
55
+ end
56
+ alias estimate calculate
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module GlobalStocks
6
+ ##
7
+ # A single executed Global Stocks (US equity) trade.
8
+ #
9
+ # @example Today's US trades
10
+ # DhanHQ::Models::GlobalStocks::Trade.all.each do |t|
11
+ # puts "#{t.trading_symbol} #{t.traded_quantity} @ $#{t.traded_price}"
12
+ # end
13
+ #
14
+ # @example Trades for one symbol
15
+ # DhanHQ::Models::GlobalStocks::Trade.find_by_security_id("AAPL")
16
+ #
17
+ class Trade < BaseModel
18
+ HTTP_PATH = "/v2/globalstocks/trades"
19
+
20
+ attributes :dhan_client_id, :order_id, :exchange_order_id, :transaction_type,
21
+ :exchange_segment, :product_type, :order_type, :trading_symbol,
22
+ :security_id, :traded_quantity, :traded_price, :trade_date,
23
+ :create_time, :exchange_time, :order_status, :brokerage,
24
+ :other_charges, :trade_value
25
+
26
+ # Returns a concise prompt-friendly summary of the trade.
27
+ def to_prompt
28
+ parts = ["#{transaction_type} #{traded_quantity}x #{trading_symbol || security_id}"]
29
+ parts << "@ $#{traded_price}" if traded_price
30
+ parts << "value=$#{trade_value}" if trade_value
31
+ parts << "on #{trade_date}" if trade_date
32
+ parts.join(", ")
33
+ end
34
+
35
+ # Total cost of the trade, brokerage and other charges combined.
36
+ #
37
+ # @return [Float]
38
+ def total_charges
39
+ brokerage.to_f + other_charges.to_f
40
+ end
41
+
42
+ class << self
43
+ # @return [DhanHQ::Resources::GlobalStocks::Trades]
44
+ def resource
45
+ @resource ||= DhanHQ::Resources::GlobalStocks::Trades.new
46
+ end
47
+
48
+ # Retrieves the current trading day's executed Global Stocks trades.
49
+ #
50
+ # @return [Array<Trade>]
51
+ def all
52
+ build_collection(resource.all)
53
+ end
54
+
55
+ # Retrieves executed Global Stocks trades for a single security.
56
+ #
57
+ # @param security_id [String]
58
+ # @return [Array<Trade>]
59
+ def find_by_security_id(security_id)
60
+ build_collection(resource.by_security_id(security_id))
61
+ end
62
+
63
+ private
64
+
65
+ def build_collection(response)
66
+ return [] unless response.is_a?(Array)
67
+
68
+ response.map { |trade| new(trade, skip_validation: true) }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -142,7 +142,11 @@ module DhanHQ
142
142
  # puts "Total margin: #{margin.total_margin}"
143
143
  #
144
144
  def calculate_multi(params)
145
- # Map scripts to scrip_list and include_orders to include_order if provided
145
+ # Work on a copy: callers may pass a frozen or reused hash, and normalising
146
+ # their argument in place is a side effect they cannot see from the signature.
147
+ params = params.dup
148
+
149
+ # Accept either spelling of the collection and order flags.
146
150
  params[:scrip_list] ||= params[:scripts] if params.key?(:scripts)
147
151
  params[:include_order] ||= params[:include_orders] if params.key?(:include_orders)
148
152
  params[:dhan_client_id] ||= DhanHQ.configuration.client_id
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ ##
6
+ # A basket of up to 15 orders placed in a single request via
7
+ # POST /v2/alerts/multi/orders.
8
+ #
9
+ # Unlike {AlertOrder} there is no trigger condition — every leg goes to the
10
+ # exchange immediately. The API answers with one result per leg, keyed by the
11
+ # +sequence+ you supplied, so partial acceptance is visible.
12
+ #
13
+ # @example Place a two-leg basket
14
+ # result = DhanHQ::Models::MultiOrder.place([
15
+ # { sequence: "1", transaction_type: "BUY", exchange_segment: "NSE_EQ",
16
+ # product_type: "CNC", order_type: "LIMIT", validity: "DAY",
17
+ # security_id: "11536", quantity: 1, price: 1500.0 },
18
+ # { sequence: "2", transaction_type: "BUY", exchange_segment: "NSE_EQ",
19
+ # product_type: "CNC", order_type: "MARKET", validity: "DAY",
20
+ # security_id: "1333", quantity: 2 }
21
+ # ])
22
+ #
23
+ # result.all_accepted? # => true
24
+ # result.rejected # => []
25
+ # result.order_ids # => ["112111182198", "112111182199"]
26
+ # result.for_sequence("1").order_id
27
+ #
28
+ class MultiOrder < BaseModel
29
+ HTTP_PATH = "/v2/alerts/multi/orders"
30
+
31
+ attributes :orders
32
+
33
+ ##
34
+ # One leg's result within a {MultiOrder} response.
35
+ class Leg < BaseModel
36
+ attributes :order_id, :sequence, :order_status
37
+
38
+ # @return [Boolean] True when the exchange accepted this leg.
39
+ def accepted?
40
+ !order_id.nil? && !rejected?
41
+ end
42
+
43
+ # @return [Boolean] True when this leg was rejected.
44
+ def rejected?
45
+ order_status.to_s == DhanHQ::Constants::OrderStatus::REJECTED
46
+ end
47
+
48
+ # Returns a concise prompt-friendly summary of the leg.
49
+ def to_prompt
50
+ "seq=#{sequence} order_id=#{order_id} status=#{order_status}"
51
+ end
52
+ end
53
+
54
+ # Per-leg results.
55
+ #
56
+ # @return [Array<Leg>]
57
+ def legs
58
+ @legs ||= Array(orders).map { |leg| Leg.new(leg, skip_validation: true) }
59
+ end
60
+
61
+ # Order ids of every leg the API returned one for.
62
+ #
63
+ # @return [Array<String>]
64
+ def order_ids
65
+ legs.filter_map(&:order_id)
66
+ end
67
+
68
+ # Legs the exchange accepted.
69
+ #
70
+ # @return [Array<Leg>]
71
+ def accepted
72
+ legs.select(&:accepted?)
73
+ end
74
+
75
+ # Legs the exchange rejected.
76
+ #
77
+ # @return [Array<Leg>]
78
+ def rejected
79
+ legs.reject(&:accepted?)
80
+ end
81
+
82
+ # @return [Boolean] True when every leg was accepted.
83
+ def all_accepted?
84
+ legs.any? && rejected.empty?
85
+ end
86
+
87
+ # @return [Boolean] True when some but not all legs were accepted.
88
+ def partially_accepted?
89
+ accepted.any? && rejected.any?
90
+ end
91
+
92
+ # Looks up a leg by the sequence supplied at placement.
93
+ #
94
+ # @param sequence [String, Integer]
95
+ # @return [Leg, nil]
96
+ def for_sequence(sequence)
97
+ legs.find { |leg| leg.sequence.to_s == sequence.to_s }
98
+ end
99
+
100
+ # Returns a concise prompt-friendly summary of the basket.
101
+ def to_prompt
102
+ "#{legs.size} legs, #{accepted.size} accepted, #{rejected.size} rejected"
103
+ end
104
+
105
+ class << self
106
+ # @return [DhanHQ::Resources::MultiOrders]
107
+ def resource
108
+ @resource ||= DhanHQ::Resources::MultiOrders.new
109
+ end
110
+
111
+ # Places a basket of orders.
112
+ #
113
+ # @param orders [Array<Hash>] Order legs. See {DhanHQ::Contracts::MultiOrderContract}.
114
+ # @param dhan_client_id [String, nil] Defaults to the configured client id.
115
+ # @return [MultiOrder, DhanHQ::ErrorObject]
116
+ def place(orders, dhan_client_id: nil)
117
+ DhanHQ.logger&.info("[#{name}] Placing multi order with #{Array(orders).size} legs")
118
+ response = resource.create(orders, dhan_client_id: dhan_client_id)
119
+
120
+ # The API may answer with a bare array of leg results or wrap it in `orders`.
121
+ payload = response.is_a?(Array) ? { orders: response } : response
122
+ return DhanHQ::ErrorObject.new(response) unless payload.is_a?(Hash) && payload[:orders]
123
+
124
+ new(payload, skip_validation: true)
125
+ end
126
+ alias create place
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the Global Stocks fund limit.
7
+ #
8
+ # GET /v2/globalstocks/fundlimit
9
+ #
10
+ # Global Stocks funds are held in USD and are reported separately from the
11
+ # domestic INR fund limit exposed by {DhanHQ::Resources::Funds}.
12
+ class Funds < BaseAPI
13
+ API_TYPE = :order_api
14
+ HTTP_PATH = "/v2/globalstocks/fundlimit"
15
+
16
+ # Retrieves the authenticated user's US stock fund limit details.
17
+ #
18
+ # @return [Hash]
19
+ def fetch
20
+ get("")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for Global Stocks holdings.
7
+ #
8
+ # GET /v2/globalstocks/holdings
9
+ class Holdings < BaseAPI
10
+ API_TYPE = :order_api
11
+ HTTP_PATH = "/v2/globalstocks/holdings"
12
+
13
+ # Retrieves the authenticated user's US stock holdings.
14
+ #
15
+ # @return [Array<Hash>]
16
+ def all
17
+ get("")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the Global Stocks pre-trade calculators.
7
+ #
8
+ # POST /v2/globalstocks/margincalculator — margin required for an order
9
+ # POST /v2/globalstocks/transEstimate — charges applicable to an order
10
+ #
11
+ # Both endpoints take the same request body. The API documents +price+ and
12
+ # +quantity+ as strings, so numeric input is stringified before sending.
13
+ class MarginCalculator < BaseAPI
14
+ API_TYPE = :non_trading_api
15
+ HTTP_PATH = "/v2/globalstocks"
16
+
17
+ # Calculates the margin required for a Global Stocks order.
18
+ #
19
+ # @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
20
+ # @return [Hash] Margin breakdown.
21
+ def calculate(params)
22
+ post("/margincalculator", params: wire_params(params))
23
+ end
24
+
25
+ # Estimates the charges applicable to a Global Stocks order.
26
+ #
27
+ # @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
28
+ # @return [Hash] Charge breakdown.
29
+ def estimate(params)
30
+ post("/transEstimate", params: wire_params(params))
31
+ end
32
+
33
+ private
34
+
35
+ # Validates against the shared estimator contract, then renders the numeric
36
+ # fields as strings the way the API expects them.
37
+ def wire_params(params)
38
+ validated = validate!(params)
39
+ validated.merge(
40
+ price: format_number(validated[:price]),
41
+ quantity: format_number(validated[:quantity])
42
+ )
43
+ end
44
+
45
+ # Fields the API documents as strings but which are validated as numbers.
46
+ NUMERIC_KEYS = %i[price quantity].freeze
47
+ private_constant :NUMERIC_KEYS
48
+
49
+ def validate!(params)
50
+ attrs = snake_case(params).each_with_object({}) do |(key, value), out|
51
+ out[key] = NUMERIC_KEYS.include?(key) ? Float(value) : value
52
+ end
53
+ result = Contracts::GlobalStocksEstimatorContract.new.call(attrs)
54
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
55
+
56
+ result.to_h
57
+ rescue ArgumentError, TypeError => e
58
+ raise DhanHQ::ValidationError, "Invalid parameters: price and quantity must be numeric (#{e.message})"
59
+ end
60
+
61
+ # Renders 180.0 as "180" and 1.5 as "1.5" so the payload matches the
62
+ # documented examples rather than leaking Ruby float formatting.
63
+ def format_number(value)
64
+ float = Float(value)
65
+ float == float.to_i ? float.to_i.to_s : float.to_s
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the US market session status.
7
+ #
8
+ # GET /v2/globalstocks/marketstatus
9
+ class MarketStatus < BaseAPI
10
+ API_TYPE = :non_trading_api
11
+ HTTP_PATH = "/v2/globalstocks/marketstatus"
12
+
13
+ # Retrieves the current US market status and session timings.
14
+ #
15
+ # @return [Hash] `{ status:, marketOpenTime:, marketCloseTime:, holidayFlag: }`
16
+ def fetch
17
+ get("")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../concerns/order_audit"
4
+
5
+ module DhanHQ
6
+ module Resources
7
+ module GlobalStocks
8
+ # Resource client for the Global Stocks (US equities) order endpoints.
9
+ #
10
+ # POST /v2/globalstocks/orders
11
+ # PUT /v2/globalstocks/orders/{order-id}
12
+ # DELETE /v2/globalstocks/orders/{order-id}
13
+ # GET /v2/globalstocks/orders
14
+ # GET /v2/globalstocks/orders/{order-id}
15
+ #
16
+ # Writes go through the same {DhanHQ::Concerns::OrderAudit} guardrails as
17
+ # domestic orders: +ENV["LIVE_TRADING"]="true"+ is required, and every attempt
18
+ # emits a structured audit log line.
19
+ #
20
+ # The pre-trade {DhanHQ::Risk::Pipeline} is intentionally *not* run here. Its
21
+ # checks (lot-size multiples, ASM/GSM surveillance lists, F&O product support,
22
+ # NSE/BSE market hours) resolve instruments from the Indian scrip master, which
23
+ # does not contain US securities.
24
+ #
25
+ # @see https://dhanhq.co/docs/v2/ Global Stocks section
26
+ class Orders < BaseAPI
27
+ include DhanHQ::Concerns::OrderAudit
28
+
29
+ API_TYPE = :order_api
30
+ HTTP_PATH = "/v2/globalstocks/orders"
31
+
32
+ # Places a new Global Stocks order.
33
+ #
34
+ # @param params [Hash] Order attributes in snake_case. See
35
+ # {DhanHQ::Contracts::GlobalStocksPlaceOrderContract} for the accepted keys.
36
+ # @return [Hash] `{ orderId:, orderStatus: }`
37
+ # @raise [DhanHQ::LiveTradingDisabledError] Unless LIVE_TRADING is enabled.
38
+ # @raise [DhanHQ::ValidationError] When the payload fails validation.
39
+ def create(params)
40
+ ensure_live_trading!
41
+ log_order_context("DHAN_GLOBAL_ORDER_ATTEMPT", params)
42
+ validate_place_order!(params)
43
+ post("", params: params)
44
+ end
45
+
46
+ # Modifies a pending Global Stocks order.
47
+ #
48
+ # @param order_id [String]
49
+ # @param params [Hash] Fields to change, in snake_case.
50
+ # @return [Hash] `{ orderId:, orderStatus: }`
51
+ def update(order_id, params)
52
+ ensure_live_trading!
53
+ log_order_context("DHAN_GLOBAL_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
54
+ validate_modify_order!(params.merge(order_id: order_id))
55
+ put("/#{order_id}", params: params)
56
+ end
57
+
58
+ # Cancels a pending Global Stocks order.
59
+ #
60
+ # @param order_id [String]
61
+ # @return [Hash] `{ orderId:, orderStatus: }`
62
+ def cancel(order_id)
63
+ ensure_live_trading!
64
+ log_order_context("DHAN_GLOBAL_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
65
+ delete("/#{order_id}")
66
+ end
67
+
68
+ # Retrieves the current trading day's Global Stocks order book.
69
+ #
70
+ # @return [Array<Hash>]
71
+ def all
72
+ get("")
73
+ end
74
+
75
+ # Retrieves a single Global Stocks order by its Dhan order id.
76
+ #
77
+ # @param order_id [String]
78
+ # @return [Hash]
79
+ def find(order_id)
80
+ get("/#{order_id}")
81
+ end
82
+
83
+ private
84
+
85
+ def validate_place_order!(params)
86
+ result = Contracts::GlobalStocksPlaceOrderContract.new.call(coerce_for_validation(params))
87
+ raise_validation_error!(result) unless result.success?
88
+ end
89
+
90
+ def validate_modify_order!(params)
91
+ result = Contracts::GlobalStocksModifyOrderContract.new.call(coerce_for_validation(params))
92
+ raise_validation_error!(result) unless result.success?
93
+ end
94
+
95
+ # Global Stocks quantities and prices are floats (fractional shares are
96
+ # supported), so integers handed in by callers are widened before validation.
97
+ def coerce_for_validation(params)
98
+ snake_case(params).each_with_object({}) do |(key, value), out|
99
+ out[key] = FLOAT_KEYS.include?(key) && value.is_a?(Integer) ? value.to_f : value
100
+ end
101
+ end
102
+
103
+ FLOAT_KEYS = %i[quantity price trigger_price stop_loss_price target_price amount].freeze
104
+ private_constant :FLOAT_KEYS
105
+
106
+ def raise_validation_error!(result)
107
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the Global Stocks trade book.
7
+ #
8
+ # GET /v2/globalstocks/trades
9
+ # GET /v2/globalstocks/trades/{security-id}
10
+ class Trades < BaseAPI
11
+ API_TYPE = :order_api
12
+ HTTP_PATH = "/v2/globalstocks/trades"
13
+
14
+ # Retrieves the current trading day's executed Global Stocks trades.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def all
18
+ get("")
19
+ end
20
+
21
+ # Retrieves executed Global Stocks trades for a single security.
22
+ #
23
+ # @param security_id [String]
24
+ # @return [Array<Hash>]
25
+ def by_security_id(security_id)
26
+ get("/#{security_id}")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../concerns/order_audit"
4
+
5
+ module DhanHQ
6
+ module Resources
7
+ # Resource for basket orders: POST /v2/alerts/multi/orders.
8
+ #
9
+ # Places up to {DhanHQ::Contracts::MultiOrderContract::MAX_ORDERS} unconditional
10
+ # orders in a single request. Unlike {AlertOrders} there is no trigger condition —
11
+ # every leg is sent to the exchange immediately.
12
+ #
13
+ # @see https://dhanhq.co/docs/v2/ Conditional and Multi Order section
14
+ class MultiOrders < BaseAPI
15
+ include DhanHQ::Concerns::OrderAudit
16
+
17
+ API_TYPE = :order_api
18
+ HTTP_PATH = "/v2/alerts/multi/orders"
19
+
20
+ # Places a basket of orders.
21
+ #
22
+ # @param orders [Array<Hash>] Order legs in snake_case. Each leg requires
23
+ # +:sequence+, +:transaction_type+ and +:exchange_segment+.
24
+ # @param dhan_client_id [String, nil] Defaults to the configured client id.
25
+ # @return [Hash] `{ orders: [{ orderId:, sequence:, orderStatus: }, ...] }`
26
+ # @raise [DhanHQ::LiveTradingDisabledError] Unless LIVE_TRADING is enabled.
27
+ # @raise [DhanHQ::ValidationError] When any leg fails validation.
28
+ def create(orders, dhan_client_id: nil)
29
+ ensure_live_trading!
30
+
31
+ legs = Array(orders).map { |leg| snake_case(leg) }
32
+ payload = { dhan_client_id: dhan_client_id || DhanHQ.configuration&.client_id, orders: legs }.compact
33
+
34
+ validate!(payload)
35
+ legs.each { |leg| run_risk_checks!(leg) }
36
+ log_multi_order_context(legs)
37
+
38
+ # BaseAPI camelizes only the top level, which would leave the leg fields as
39
+ # `transaction_type`/`security_id` inside `orders` and get the basket rejected.
40
+ post("", params: payload.merge(orders: deep_camelize_keys(legs)))
41
+ end
42
+
43
+ private
44
+
45
+ def validate!(payload)
46
+ result = Contracts::MultiOrderContract.new.call(payload)
47
+ raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
48
+ end
49
+
50
+ # Emits one audit line per leg so each order in the basket is traceable.
51
+ def log_multi_order_context(legs)
52
+ legs.each do |leg|
53
+ log_order_context("DHAN_MULTI_ORDER_ATTEMPT", leg)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "3.1.0"
5
+ VERSION = "3.2.0"
6
6
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Classifies request paths by whether they change account state.
5
+ #
6
+ # The distinction that matters on this API is **mutating vs read-only**, not GET vs
7
+ # POST: the option chain, market feed, historical charts and the margin calculators
8
+ # are all read-only POSTs. Both dry-run simulation and non-idempotent retry gating
9
+ # need that answer, so it lives here rather than in {DhanHQ::Client}.
10
+ module WritePaths
11
+ # HTTP methods that can change server-side state.
12
+ WRITE_METHODS = %i[post put patch delete].freeze
13
+
14
+ # Path of the basket order endpoint, which answers with one result per leg
15
+ # rather than a single order id.
16
+ MULTI_ORDER_PATH = "/v2/alerts/multi/orders"
17
+
18
+ module_function
19
+
20
+ # True when this request changes real account state.
21
+ #
22
+ # @param method [Symbol] HTTP method
23
+ # @param path [String, nil] Request path
24
+ # @return [Boolean]
25
+ def mutating?(method, path)
26
+ return false unless WRITE_METHODS.include?(method)
27
+
28
+ match?(path, DhanHQ::Constants::MUTATING_PATH_PREFIXES)
29
+ end
30
+
31
+ # True when this request places one or more orders.
32
+ #
33
+ # @param method [Symbol] HTTP method
34
+ # @param path [String, nil] Request path
35
+ # @return [Boolean]
36
+ def order_placement?(method, path)
37
+ return false unless method == :post
38
+
39
+ match?(path, DhanHQ::Constants::ORDER_PLACEMENT_PATH_PREFIXES)
40
+ end
41
+
42
+ # True for the basket order endpoint.
43
+ #
44
+ # @param path [String, nil] Request path
45
+ # @return [Boolean]
46
+ def multi_order?(path)
47
+ path.to_s.start_with?(MULTI_ORDER_PATH)
48
+ end
49
+
50
+ def match?(path, prefixes)
51
+ return false if path.nil?
52
+
53
+ prefixes.any? { |prefix| path.start_with?(prefix) }
54
+ end
55
+ private_class_method :match?
56
+ end
57
+ end