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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module GlobalStocks
6
+ ##
7
+ # Margin requirement for a prospective Global Stocks order.
8
+ #
9
+ # @example Check affordability before placing
10
+ # margin = DhanHQ::Models::GlobalStocks::Margin.calculate(
11
+ # security_id: "AAPL", transaction_type: "BUY", price: 180.0, quantity: 10
12
+ # )
13
+ # puts "Need $#{margin.total_margin}, have $#{margin.available_bal}"
14
+ # raise "Insufficient funds" unless margin.sufficient?
15
+ #
16
+ class Margin < BaseModel
17
+ HTTP_PATH = "/v2/globalstocks/margincalculator"
18
+
19
+ attributes :insufficient_bal, :brokerage, :leverage, :total_margin, :available_bal
20
+
21
+ # Returns a concise prompt-friendly summary of the margin requirement.
22
+ def to_prompt
23
+ parts = []
24
+ parts << "total_margin=$#{total_margin}" if total_margin
25
+ parts << "available=$#{available_bal}" if available_bal
26
+ parts << "brokerage=$#{brokerage}" if brokerage
27
+ parts << "leverage=#{leverage}" if leverage
28
+ parts << "shortfall=$#{insufficient_bal}" if insufficient_bal.to_f.positive?
29
+ parts.join(", ")
30
+ end
31
+
32
+ # @return [Boolean] True when the account can fund the order.
33
+ def sufficient?
34
+ !insufficient_bal.to_f.positive?
35
+ end
36
+
37
+ class << self
38
+ # @return [DhanHQ::Resources::GlobalStocks::MarginCalculator]
39
+ def resource
40
+ @resource ||= DhanHQ::Resources::GlobalStocks::MarginCalculator.new
41
+ end
42
+
43
+ # Calculates the margin required for a Global Stocks order.
44
+ #
45
+ # @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
46
+ # @return [Margin]
47
+ def calculate(params)
48
+ new(resource.calculate(params), skip_validation: true)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ module GlobalStocks
6
+ ##
7
+ # Current US market session status.
8
+ #
9
+ # @example Gate a strategy on the US session
10
+ # status = DhanHQ::Models::GlobalStocks::MarketStatus.fetch
11
+ # return unless status.open?
12
+ #
13
+ class MarketStatus < BaseModel
14
+ HTTP_PATH = "/v2/globalstocks/marketstatus"
15
+
16
+ attributes :status, :market_open_time, :market_close_time, :holiday_flag
17
+
18
+ # Returns a concise prompt-friendly summary of the session.
19
+ def to_prompt
20
+ parts = ["US market #{status}"]
21
+ parts << "open=#{market_open_time}" if market_open_time
22
+ parts << "close=#{market_close_time}" if market_close_time
23
+ parts << "holiday" if holiday?
24
+ parts.join(", ")
25
+ end
26
+
27
+ # @return [Boolean] True when the US market is currently open.
28
+ def open?
29
+ status.to_s.casecmp(DhanHQ::Constants::GlobalStocks::MarketStatus::OPEN).zero?
30
+ end
31
+
32
+ # @return [Boolean] True when the US market is currently closed.
33
+ def closed?
34
+ !open?
35
+ end
36
+
37
+ # @return [Boolean] True when today is a US market holiday.
38
+ def holiday?
39
+ holiday_flag == true
40
+ end
41
+
42
+ class << self
43
+ # @return [DhanHQ::Resources::GlobalStocks::MarketStatus]
44
+ def resource
45
+ @resource ||= DhanHQ::Resources::GlobalStocks::MarketStatus.new
46
+ end
47
+
48
+ # Fetches the current US market status.
49
+ #
50
+ # @return [MarketStatus]
51
+ def fetch
52
+ new(resource.fetch, skip_validation: true)
53
+ end
54
+
55
+ # @return [Boolean] True when the US market is currently open.
56
+ def open?
57
+ fetch.open?
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ # Typed models for the Global Stocks (US equities) APIs.
6
+ #
7
+ # Global Stocks are a separate book from domestic NSE/BSE trading: separate
8
+ # order book, holdings, trades and a USD fund limit. They are namespaced so
9
+ # `Models::GlobalStocks::Holding` can never be confused with the domestic
10
+ # {DhanHQ::Models::Holding}.
11
+ module GlobalStocks
12
+ ##
13
+ # A single Global Stocks (US equity) order.
14
+ #
15
+ # @example Place a limit order for one share of AAPL
16
+ # order = DhanHQ::Models::GlobalStocks::Order.place(
17
+ # transaction_type: "BUY",
18
+ # order_type: "LIMIT",
19
+ # security_id: "AAPL",
20
+ # quantity: 1,
21
+ # price: 180.0
22
+ # )
23
+ # order.order_id # => "112111182198"
24
+ #
25
+ # @example Place a notional ($ value) order
26
+ # DhanHQ::Models::GlobalStocks::Order.place(
27
+ # transaction_type: "BUY",
28
+ # order_type: "AMOUNT",
29
+ # security_id: "AAPL",
30
+ # amount: 500.0
31
+ # )
32
+ #
33
+ # @example Fetch the order book
34
+ # DhanHQ::Models::GlobalStocks::Order.all.each do |o|
35
+ # puts "#{o.trading_symbol} #{o.order_status} #{o.traded_qty}/#{o.quantity}"
36
+ # end
37
+ #
38
+ class Order < BaseModel
39
+ HTTP_PATH = "/v2/globalstocks/orders"
40
+
41
+ attributes :dhan_client_id, :order_id, :exchange_order_id, :correlation_id,
42
+ :transaction_type, :exchange_segment, :product_type, :order_type,
43
+ :validity, :trading_symbol, :display_name, :security_id,
44
+ :quantity, :remaining_quantity, :traded_qty, :price, :trigger_price,
45
+ :avg_traded_price, :order_status, :create_time, :exchange_time,
46
+ :update_time, :oms_error_code, :oms_error_description,
47
+ :after_market_order, :amount, :lot_size, :fractional_flag,
48
+ :leg_name, :child_orders
49
+
50
+ # Returns a concise prompt-friendly summary of the order.
51
+ def to_prompt
52
+ parts = ["#{transaction_type} #{quantity || amount}x #{trading_symbol || security_id}"]
53
+ parts << "type=#{order_type}"
54
+ parts << "price=$#{price}" if price&.positive?
55
+ parts << "status=#{order_status}" if order_status
56
+ parts << "traded=#{traded_qty}" if traded_qty&.positive?
57
+ parts << "avg=$#{avg_traded_price}" if avg_traded_price&.positive?
58
+ parts << "error=#{oms_error_description}" if oms_error_description
59
+ parts.join(", ")
60
+ end
61
+
62
+ # @return [Boolean] True when the order can still be modified or cancelled.
63
+ def pending?
64
+ [DhanHQ::Constants::OrderStatus::PENDING,
65
+ DhanHQ::Constants::OrderStatus::TRANSIT,
66
+ DhanHQ::Constants::OrderStatus::PART_TRADED].include?(order_status)
67
+ end
68
+
69
+ # @return [Boolean] True when the order is fully executed.
70
+ def traded?
71
+ order_status == DhanHQ::Constants::OrderStatus::TRADED
72
+ end
73
+
74
+ # @return [Boolean] True when the order reached a terminal state.
75
+ def closed?
76
+ [DhanHQ::Constants::OrderStatus::TRADED,
77
+ DhanHQ::Constants::OrderStatus::CANCELLED,
78
+ DhanHQ::Constants::OrderStatus::REJECTED,
79
+ DhanHQ::Constants::OrderStatus::EXPIRED].include?(order_status)
80
+ end
81
+
82
+ # @return [Boolean] True for a notional (dollar-value) order.
83
+ def notional?
84
+ order_type == DhanHQ::Constants::GlobalStocks::OrderType::AMOUNT
85
+ end
86
+
87
+ # @return [Boolean] True when the order is for a fractional share quantity.
88
+ def fractional?
89
+ return true if fractional_flag
90
+
91
+ quantity.is_a?(Float) && (quantity % 1 != 0)
92
+ end
93
+
94
+ # Child legs of a Global Stocks super order, as {Order} instances.
95
+ #
96
+ # @return [Array<Order>]
97
+ def child_legs
98
+ Array(child_orders).map { |leg| self.class.new(leg, skip_validation: true) }
99
+ end
100
+
101
+ # Modifies this order in place and returns the refreshed record.
102
+ #
103
+ # @param params [Hash] Fields to change, in snake_case.
104
+ # @return [Order, DhanHQ::ErrorObject]
105
+ def modify(params = {})
106
+ payload = { transaction_type: transaction_type, order_type: order_type, security_id: security_id }
107
+ .merge(params)
108
+ response = self.class.resource.update(order_id, payload)
109
+ return DhanHQ::ErrorObject.new(response) unless self.class.send(:successful?, response)
110
+
111
+ self.class.find(order_id)
112
+ end
113
+
114
+ # Cancels this order.
115
+ #
116
+ # @return [Boolean] True when the API confirms cancellation.
117
+ def cancel
118
+ response = self.class.resource.cancel(order_id)
119
+ status = response.is_a?(Hash) ? (response[:orderStatus] || response[:order_status]) : nil
120
+ cancelled = status.to_s == DhanHQ::Constants::OrderStatus::CANCELLED
121
+ DhanHQ.logger&.error("[#{self.class.name}] Cancel failed for order #{order_id}: #{response}") unless cancelled
122
+ cancelled
123
+ end
124
+
125
+ class << self
126
+ # @return [DhanHQ::Resources::GlobalStocks::Orders]
127
+ def resource
128
+ @resource ||= DhanHQ::Resources::GlobalStocks::Orders.new
129
+ end
130
+
131
+ # Retrieves the current trading day's Global Stocks order book.
132
+ #
133
+ # @return [Array<Order>]
134
+ def all
135
+ response = resource.all
136
+ return [] unless response.is_a?(Array)
137
+
138
+ response.map { |order| new(order, skip_validation: true) }
139
+ end
140
+
141
+ # Finds a single Global Stocks order by its Dhan order id.
142
+ #
143
+ # @param order_id [String]
144
+ # @return [Order, nil]
145
+ def find(order_id)
146
+ response = resource.find(order_id)
147
+ payload = response.is_a?(Array) ? response.first : response
148
+ return nil unless payload.is_a?(Hash) && !payload.empty?
149
+
150
+ new(payload, skip_validation: true)
151
+ end
152
+
153
+ # Places a new Global Stocks order.
154
+ #
155
+ # @param params [Hash] See {DhanHQ::Contracts::GlobalStocksPlaceOrderContract}.
156
+ # @return [Order, DhanHQ::ErrorObject] The placed order, refetched so the
157
+ # caller gets the full record rather than just `{ orderId:, orderStatus: }`.
158
+ def place(params)
159
+ DhanHQ.logger&.info("[#{name}] Placing global order: #{params}")
160
+ response = resource.create(params)
161
+ return DhanHQ::ErrorObject.new(response) unless successful?(response)
162
+
163
+ id = response[:orderId] || response[:order_id]
164
+ DhanHQ.logger&.info("[#{name}] Global order placed successfully: #{id}")
165
+ find(id) || new(response, skip_validation: true)
166
+ end
167
+ alias create place
168
+
169
+ # Cancels a Global Stocks order by id.
170
+ #
171
+ # @param order_id [String]
172
+ # @return [Boolean]
173
+ def cancel(order_id)
174
+ response = resource.cancel(order_id)
175
+ status = response.is_a?(Hash) ? (response[:orderStatus] || response[:order_status]) : nil
176
+ status.to_s == DhanHQ::Constants::OrderStatus::CANCELLED
177
+ end
178
+
179
+ private
180
+
181
+ # An order write succeeded when the API echoed an order id back.
182
+ def successful?(response)
183
+ response.is_a?(Hash) && !(response[:orderId] || response[:order_id]).nil?
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -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