DhanHQ 2.8.0 → 3.0.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +7 -0
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +16 -5
  5. data/docs/RAILS_WEBSOCKET_INTEGRATION.md +1 -1
  6. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +1 -1
  7. data/docs/TECHNICAL_ANALYSIS.md +1 -1
  8. data/exe/DhanHQ +1 -1
  9. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  10. data/lib/DhanHQ/agent/policy.rb +51 -0
  11. data/lib/DhanHQ/agent/tool_registry.rb +250 -0
  12. data/lib/DhanHQ/agent.rb +12 -0
  13. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  14. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  15. data/lib/DhanHQ/ai.rb +27 -0
  16. data/lib/DhanHQ/auth.rb +0 -1
  17. data/lib/DhanHQ/client.rb +1 -3
  18. data/lib/DhanHQ/constants.rb +2 -0
  19. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  20. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  21. data/lib/DhanHQ/core/auth_api.rb +0 -1
  22. data/lib/DhanHQ/errors.rb +4 -0
  23. data/lib/DhanHQ/events/base.rb +203 -0
  24. data/lib/DhanHQ/events/bus.rb +158 -0
  25. data/lib/DhanHQ/events.rb +40 -0
  26. data/lib/DhanHQ/indicators.rb +283 -0
  27. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  28. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  29. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  30. data/lib/DhanHQ/market_data.rb +25 -0
  31. data/lib/DhanHQ/mcp/server.rb +72 -0
  32. data/lib/DhanHQ/mcp.rb +10 -0
  33. data/lib/DhanHQ/models/funds.rb +12 -0
  34. data/lib/DhanHQ/models/holding.rb +42 -0
  35. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  36. data/lib/DhanHQ/models/instrument.rb +36 -0
  37. data/lib/DhanHQ/models/order.rb +95 -0
  38. data/lib/DhanHQ/models/position.rb +66 -0
  39. data/lib/DhanHQ/models/search_result.rb +12 -0
  40. data/lib/DhanHQ/models/trade.rb +13 -0
  41. data/lib/DhanHQ/models/twap_order.rb +136 -0
  42. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  43. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  44. data/lib/DhanHQ/option_analytics.rb +36 -0
  45. data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
  46. data/lib/DhanHQ/resources/twap_orders.rb +61 -0
  47. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  48. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  49. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  50. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  51. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  52. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  53. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +65 -0
  55. data/lib/DhanHQ/risk.rb +250 -0
  56. data/lib/DhanHQ/skills/base.rb +132 -0
  57. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
  58. data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
  59. data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
  60. data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
  61. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  62. data/lib/DhanHQ/skills/registry.rb +101 -0
  63. data/lib/DhanHQ/skills/workflow.rb +66 -0
  64. data/lib/DhanHQ/skills.rb +29 -0
  65. data/lib/DhanHQ/strategy/base.rb +189 -0
  66. data/lib/DhanHQ/strategy.rb +40 -0
  67. data/lib/DhanHQ/version.rb +1 -1
  68. data/lib/DhanHQ/ws/decoder.rb +57 -19
  69. data/lib/DhanHQ.rb +3 -0
  70. data/lib/dhan_hq/agent.rb +3 -0
  71. data/lib/dhan_hq/analysis.rb +9 -0
  72. data/lib/dhan_hq/mcp.rb +3 -0
  73. data/lib/dhan_hq/ta.rb +5 -0
  74. data/lib/dhan_hq.rb +27 -4
  75. data/lib/ta/technical_analysis.rb +3 -1
  76. data/skills/dhanhq-ruby/SKILL.md +74 -0
  77. data/skills/dhanhq-ruby/references/market_data.md +3 -0
  78. data/skills/dhanhq-ruby/references/orders.md +7 -0
  79. metadata +61 -20
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module MarketData
5
+ # A time series of OHLCV candles for a single instrument.
6
+ #
7
+ # Wraps historical data responses into a convenient array-like structure
8
+ # with helper methods for analysis.
9
+ #
10
+ # @example Build a series from historical data response
11
+ # response = DhanHQ::Models::HistoricalData.daily(
12
+ # security_id: "11536",
13
+ # exchange_segment: "NSE_EQ",
14
+ # instrument: "EQUITY",
15
+ # from_date: "2024-01-01",
16
+ # to_date: "2024-12-31"
17
+ # )
18
+ # series = DhanHQ::MarketData::OHLCSeries.from_response(response)
19
+ # series.closes #=> [2800.0, 2810.5, ...]
20
+ # series.volumes #=> [123456, 234567, ...]
21
+ #
22
+ class OHLCSeries
23
+ include Enumerable
24
+
25
+ Candle = Struct.new(:timestamp, :open, :high, :low, :close, :volume, :open_interest) do
26
+ def body_size
27
+ (close - open).abs
28
+ end
29
+
30
+ def upper_shadow
31
+ high - [open, close].max
32
+ end
33
+
34
+ def lower_shadow
35
+ [open, close].min - low
36
+ end
37
+
38
+ def bullish?
39
+ close > open
40
+ end
41
+
42
+ def bearish?
43
+ close < open
44
+ end
45
+
46
+ def doji?
47
+ (close - open).abs < (high - low) * 0.1
48
+ end
49
+ end
50
+
51
+ attr_reader :candles, :security_id, :exchange_segment
52
+
53
+ def initialize(candles = [], metadata = {})
54
+ @candles = candles
55
+ @security_id = metadata[:security_id]
56
+ @exchange_segment = metadata[:exchange_segment]
57
+ end
58
+
59
+ # Build an OHLCSeries from a raw historical data API response.
60
+ def self.from_response(response)
61
+ data = response.is_a?(Hash) ? (response[:data] || response["data"] || response) : response
62
+ data = [data] unless data.is_a?(Array)
63
+
64
+ candles = data.map do |candle|
65
+ Candle.new(
66
+ timestamp: candle[:timestamp] || candle["timestamp"],
67
+ open: (candle[:open] || candle["open"]).to_f,
68
+ high: (candle[:high] || candle["high"]).to_f,
69
+ low: (candle[:low] || candle["low"]).to_f,
70
+ close: (candle[:close] || candle["close"]).to_f,
71
+ volume: (candle[:volume] || candle["volume"]).to_i,
72
+ open_interest: candle[:open_interest] || candle["open_interest"]
73
+ )
74
+ end
75
+
76
+ new(candles)
77
+ end
78
+
79
+ def each(&)
80
+ @candles.each(&)
81
+ end
82
+
83
+ def size
84
+ @candles.size
85
+ end
86
+
87
+ def empty?
88
+ @candles.empty?
89
+ end
90
+
91
+ # Get all close prices.
92
+ def closes
93
+ @candles.map(&:close)
94
+ end
95
+
96
+ # Get all open prices.
97
+ def opens
98
+ @candles.map(&:open)
99
+ end
100
+
101
+ # Get all high prices.
102
+ def highs
103
+ @candles.map(&:high)
104
+ end
105
+
106
+ # Get all low prices.
107
+ def lows
108
+ @candles.map(&:low)
109
+ end
110
+
111
+ # Get all volumes.
112
+ def volumes
113
+ @candles.map(&:volume)
114
+ end
115
+
116
+ # Get the most recent candle.
117
+ def last
118
+ @candles.last
119
+ end
120
+
121
+ # Get the oldest candle.
122
+ def first
123
+ @candles.first
124
+ end
125
+
126
+ # Get the date range of the series.
127
+ def date_range
128
+ return nil if empty?
129
+
130
+ [first.timestamp, last.timestamp]
131
+ end
132
+
133
+ # Calculate the total volume across all candles.
134
+ def total_volume
135
+ @candles.sum(&:volume)
136
+ end
137
+
138
+ # Calculate the average close price.
139
+ def average_close
140
+ return nil if empty?
141
+
142
+ closes.sum / size
143
+ end
144
+
145
+ # Calculate the price range (highest high - lowest low).
146
+ def price_range
147
+ return nil if empty?
148
+
149
+ highs.max - lows.min
150
+ end
151
+
152
+ # Slice the series by date range (requires timestamps).
153
+ def slice_range(from_timestamp, to_timestamp)
154
+ self.class.new(
155
+ @candles.select { |c| c.timestamp.between?(from_timestamp, to_timestamp) },
156
+ { security_id: @security_id, exchange_segment: @exchange_segment }
157
+ )
158
+ end
159
+
160
+ # Take the last N candles.
161
+ def tail(count)
162
+ self.class.new(
163
+ @candles.last(count),
164
+ { security_id: @security_id, exchange_segment: @exchange_segment }
165
+ )
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module MarketData
5
+ # A snapshot of option chain data for a single underlying.
6
+ #
7
+ # Wraps the raw OptionChain response into a convenient structure
8
+ # with typed accessors and helper methods for option analysis.
9
+ #
10
+ # @example Build a snapshot from OptionChain response
11
+ # response = DhanHQ::Models::OptionChain.fetch(
12
+ # underlying_scrip: 13,
13
+ # underlying_seg: "IDX_I",
14
+ # expiry: "2024-07-25"
15
+ # )
16
+ # snapshot = DhanHQ::MarketData::OptionSnapshot.from_response(response)
17
+ # snapshot.calls_at(24000) #=> Array of call option data
18
+ # snapshot.puts_at(24000) #=> Array of put option data
19
+ #
20
+ class OptionSnapshot
21
+ OptionLeg = Struct.new(:strike_price, :option_type, :ltp, :bid, :ask,
22
+ :volume, :open_interest, :implied_volatility,
23
+ :delta, :gamma, :theta, :vega) do
24
+ def call?
25
+ option_type == DhanHQ::Constants::OptionType::CALL
26
+ end
27
+
28
+ def put?
29
+ option_type == DhanHQ::Constants::OptionType::PUT
30
+ end
31
+
32
+ def itm?(spot_price)
33
+ return false unless spot_price
34
+
35
+ call? ? strike_price < spot_price : strike_price > spot_price
36
+ end
37
+
38
+ def otm?(spot_price)
39
+ return false unless spot_price
40
+
41
+ call? ? strike_price > spot_price : strike_price < spot_price
42
+ end
43
+
44
+ def atm?(spot_price)
45
+ return false unless spot_price
46
+
47
+ (strike_price - spot_price).abs < 1
48
+ end
49
+ end
50
+
51
+ attr_reader :underlying_scrip, :underlying_seg, :expiry, :legs, :spot_price, :fetched_at
52
+
53
+ def initialize(legs = [], metadata = {})
54
+ @underlying_scrip = metadata[:underlying_scrip]
55
+ @underlying_seg = metadata[:underlying_seg]
56
+ @expiry = metadata[:expiry]
57
+ @spot_price = metadata[:spot_price]
58
+ @legs = legs
59
+ @fetched_at = Time.now
60
+ end
61
+
62
+ # Build an OptionSnapshot from a raw OptionChain API response.
63
+ def self.from_response(response)
64
+ data = response.is_a?(Hash) ? (response[:data] || response["data"] || response) : response
65
+ return new([], {}) unless data.is_a?(Hash)
66
+
67
+ legs = parse_legs(data)
68
+ metadata = {
69
+ underlying_scrip: data[:underlyingScrip] || data["underlyingScrip"],
70
+ underlying_seg: data[:underlyingSeg] || data["underlyingSeg"],
71
+ expiry: data[:expiry] || data["expiry"],
72
+ spot_price: data[:spot] || data["spot"]
73
+ }
74
+
75
+ new(legs, metadata)
76
+ end
77
+
78
+ # Get all call option legs.
79
+ def calls
80
+ @legs.select(&:call?)
81
+ end
82
+
83
+ # Get all put option legs.
84
+ def puts
85
+ @legs.select(&:put?)
86
+ end
87
+
88
+ # Get all unique strike prices.
89
+ def strikes
90
+ @legs.map(&:strike_price).uniq.sort
91
+ end
92
+
93
+ # Get option legs at a specific strike price.
94
+ def at_strike(strike_price)
95
+ @legs.select { |leg| leg.strike_price == strike_price }
96
+ end
97
+
98
+ # Get call option at a specific strike price.
99
+ def call_at(strike_price)
100
+ calls.find { |leg| leg.strike_price == strike_price }
101
+ end
102
+
103
+ # Get put option at a specific strike price.
104
+ def put_at(strike_price)
105
+ puts.find { |leg| leg.strike_price == strike_price }
106
+ end
107
+
108
+ # Get all ITM calls (calls with strike below spot).
109
+ def itm_calls
110
+ return [] unless @spot_price
111
+
112
+ calls.select { |leg| leg.itm?(@spot_price) }
113
+ end
114
+
115
+ # Get all OTM calls (calls with strike above spot).
116
+ def otm_calls
117
+ return [] unless @spot_price
118
+
119
+ calls.select { |leg| leg.otm?(@spot_price) }
120
+ end
121
+
122
+ # Get all ITM puts (puts with strike above spot).
123
+ def itm_puts
124
+ return [] unless @spot_price
125
+
126
+ puts.select { |leg| leg.itm?(@spot_price) }
127
+ end
128
+
129
+ # Get all OTM puts (puts with strike below spot).
130
+ def otm_puts
131
+ return [] unless @spot_price
132
+
133
+ puts.select { |leg| leg.otm?(@spot_price) }
134
+ end
135
+
136
+ # Find the ATM strike (closest to spot price).
137
+ def atm_strike
138
+ return nil unless @spot_price || strikes.any?
139
+
140
+ spot = @spot_price || strikes.first
141
+ strikes.min_by { |strike| (strike - spot).abs }
142
+ end
143
+
144
+ # Get the total open interest across all legs.
145
+ def total_oi
146
+ @legs.sum { |leg| leg.open_interest.to_i }
147
+ end
148
+
149
+ # Get the total volume across all legs.
150
+ def total_volume
151
+ @legs.sum { |leg| leg.volume.to_i }
152
+ end
153
+
154
+ # Calculate put-call ratio by open interest.
155
+ def pcr_by_oi
156
+ call_oi = calls.sum { |leg| leg.open_interest.to_i }
157
+ put_oi = puts.sum { |leg| leg.open_interest.to_i }
158
+ return 0.0 if call_oi.zero?
159
+
160
+ put_oi.to_f / call_oi
161
+ end
162
+
163
+ # Calculate put-call ratio by volume.
164
+ def pcr_by_volume
165
+ call_vol = calls.sum { |leg| leg.volume.to_i }
166
+ put_vol = puts.sum { |leg| leg.volume.to_i }
167
+ return 0.0 if call_vol.zero?
168
+
169
+ put_vol.to_f / call_vol
170
+ end
171
+
172
+ def self.parse_legs(data)
173
+ # Parse from the option chain structure
174
+ ce_data = data[:ce] || data["ce"] || {}
175
+ pe_data = data[:pe] || data["pe"] || {}
176
+
177
+ ce_strikes = ce_data[:strike] || ce_data["strike"] || []
178
+ pe_strikes = pe_data[:strike] || pe_data["strike"] || []
179
+
180
+ # Parse CE (call) and PE (put) legs
181
+ parse_option_legs(ce_data, DhanHQ::Constants::OptionType::CALL, ce_strikes) +
182
+ parse_option_legs(pe_data, DhanHQ::Constants::OptionType::PUT, pe_strikes)
183
+ end
184
+ private_class_method :parse_legs
185
+
186
+ def self.parse_option_legs(data, option_type, strikes)
187
+ return [] unless data.is_a?(Hash)
188
+
189
+ strikes.each_with_index.map do |strike, i|
190
+ build_leg(data, option_type, strike, i)
191
+ end
192
+ end
193
+ private_class_method :parse_option_legs
194
+
195
+ def self.build_leg(data, option_type, strike, index)
196
+ OptionLeg.new(
197
+ strike_price: strike.to_f,
198
+ option_type: option_type,
199
+ ltp: extract_value(data, :ltp, index),
200
+ bid: extract_value(data, :bid, index),
201
+ ask: extract_value(data, :ask, index),
202
+ volume: extract_value(data, :volume, index, as_integer: true),
203
+ open_interest: extract_value(data, :oi, index, as_integer: true),
204
+ implied_volatility: extract_value(data, :iv, index),
205
+ delta: extract_value(data, :delta, index),
206
+ gamma: extract_value(data, :gamma, index),
207
+ theta: extract_value(data, :theta, index),
208
+ vega: extract_value(data, :vega, index)
209
+ )
210
+ end
211
+ private_class_method :build_leg
212
+
213
+ def self.extract_value(data, key, index, as_integer: false)
214
+ values = data[key] || data[key.to_s] || []
215
+ value = values[index]
216
+ return nil if value.nil?
217
+
218
+ as_integer ? value.to_i : value.to_f
219
+ end
220
+ private_class_method :extract_value
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "market_data/market_snapshot"
4
+ require_relative "market_data/ohlc_series"
5
+ require_relative "market_data/option_snapshot"
6
+
7
+ module DhanHQ
8
+ # Higher-level abstractions for market data consumption.
9
+ #
10
+ # Provides typed wrappers around raw API responses for more convenient
11
+ # market data analysis and consumption.
12
+ #
13
+ # @example Quick market snapshot
14
+ # response = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [11536])
15
+ # snapshot = DhanHQ::MarketData::MarketSnapshot.from_response(response)
16
+ # puts snapshot.ltp("NSE_EQ", "11536")
17
+ #
18
+ # @example Historical OHLC series
19
+ # response = DhanHQ::Models::HistoricalData.daily(...)
20
+ # series = DhanHQ::MarketData::OHLCSeries.from_response(response)
21
+ # puts series.average_close
22
+ #
23
+ module MarketData
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../agent"
5
+
6
+ module DhanHQ
7
+ module MCP
8
+ # Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
9
+ class Server
10
+ def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env)
11
+ @input = input
12
+ @output = output
13
+ @policy = policy
14
+ end
15
+
16
+ def run
17
+ @input.each_line { |line| handle_line(line) }
18
+ end
19
+
20
+ def handle_line(line)
21
+ request = JSON.parse(line)
22
+ respond(request["id"], dispatch(request["method"], request["params"] || {}))
23
+ rescue StandardError => e
24
+ respond(nil, nil, code: -32_000, message: e.message)
25
+ end
26
+
27
+ private
28
+
29
+ def dispatch(method, params)
30
+ case method
31
+ when "initialize"
32
+ {
33
+ protocolVersion: "2024-11-05",
34
+ serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
35
+ capabilities: { tools: {} }
36
+ }
37
+ when "tools/list"
38
+ { tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
39
+ when "tools/call"
40
+ result = DhanHQ::Agent::ToolRegistry.execute(
41
+ params.fetch("name"),
42
+ params.fetch("arguments", {}),
43
+ policy: @policy
44
+ )
45
+ { content: [{ type: "text", text: JSON.pretty_generate(serialize(result)) }] }
46
+ else
47
+ raise ArgumentError, "Unsupported MCP method: #{method}"
48
+ end
49
+ end
50
+
51
+ def mcp_tool(tool)
52
+ { name: tool[:name], description: "[#{tool[:risk]}] #{tool[:description]}", inputSchema: tool[:input_schema] }
53
+ end
54
+
55
+ def serialize(value)
56
+ case value
57
+ when Array then value.map { |v| serialize(v) }
58
+ when Hash then value.transform_values { |v| serialize(v) }
59
+ else
60
+ value.respond_to?(:attributes) ? value.attributes : value
61
+ end
62
+ end
63
+
64
+ def respond(id, result, code: nil, message: nil)
65
+ payload = { jsonrpc: "2.0", id: id }
66
+ code ? payload[:error] = { code: code, message: message } : payload[:result] = result
67
+ @output.puts(JSON.generate(payload))
68
+ @output.flush
69
+ end
70
+ end
71
+ end
72
+ end
data/lib/DhanHQ/mcp.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcp/server"
4
+
5
+ module DhanHQ
6
+ # MCP namespace provides Model Context Protocol server for AI agent integration.
7
+ # Enables stdio-based JSON-RPC communication with AI tools and agents.
8
+ module MCP
9
+ end
10
+ end
@@ -29,6 +29,18 @@ module DhanHQ
29
29
  attributes :available_balance, :sod_limit, :collateral_amount, :receiveable_amount, :utilized_amount,
30
30
  :blocked_payout_amount, :withdrawable_balance
31
31
 
32
+ # Returns a concise prompt-friendly summary of funds.
33
+ def to_prompt
34
+ parts = []
35
+ parts << "available=₹#{available_balance}" if available_balance
36
+ parts << "utilized=₹#{utilized_amount}" if utilized_amount
37
+ parts << "withdrawable=₹#{withdrawable_balance}" if withdrawable_balance
38
+ parts << "collateral=₹#{collateral_amount}" if collateral_amount&.positive?
39
+ parts << "sod_limit=₹#{sod_limit}" if sod_limit
40
+ parts << "blocked_payout=₹#{blocked_payout_amount}" if blocked_payout_amount&.positive?
41
+ parts.join(", ")
42
+ end
43
+
32
44
  ##
33
45
  # Normalizes the typo'd `availabelBalance` key from the API response to `available_balance`.
34
46
  #
@@ -33,6 +33,48 @@ module DhanHQ
33
33
  attributes :exchange, :trading_symbol, :security_id, :isin, :total_qty,
34
34
  :dp_qty, :t1_qty, :available_qty, :collateral_qty, :avg_cost_price
35
35
 
36
+ # Returns a concise prompt-friendly summary of the holding.
37
+ def to_prompt
38
+ parts = [
39
+ "#{total_qty}x #{trading_symbol || security_id}",
40
+ "on #{exchange}"
41
+ ]
42
+ parts << "avg_cost=#{avg_cost_price}" if avg_cost_price&.positive?
43
+ parts << "available=#{available_qty}" if available_qty
44
+ parts << "dp=#{dp_qty}" if dp_qty&.positive?
45
+ parts << "t1=#{t1_qty}" if t1_qty&.positive?
46
+ parts << "collateral=#{collateral_qty}" if collateral_qty&.positive?
47
+ parts << "isin=#{isin}" if isin
48
+ parts.join(", ")
49
+ end
50
+
51
+ # Returns true if this holding has pending T1 delivery.
52
+ def pending_delivery?
53
+ t1_qty.to_i.positive?
54
+ end
55
+
56
+ # Returns true if this holding is fully delivered (no T1).
57
+ def delivered?
58
+ t1_qty.to_i.zero?
59
+ end
60
+
61
+ # Returns true if any quantity is pledged as collateral.
62
+ def pledged?
63
+ collateral_qty.to_i.positive?
64
+ end
65
+
66
+ # Returns the percentage of holding that is pledged.
67
+ def pledge_percentage
68
+ return 0.0 if total_qty.to_i.zero?
69
+
70
+ (collateral_qty.to_i.to_f / total_qty.to_i * 100).round(2)
71
+ end
72
+
73
+ # Returns true if this holding is partially pledged.
74
+ def partially_pledged?
75
+ pledged? && collateral_qty.to_i < total_qty.to_i
76
+ end
77
+
36
78
  class << self
37
79
  ##
38
80
  # Provides a shared instance of the Holdings resource.