DhanHQ 2.1.5 → 2.1.6

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/GUIDE.md +215 -73
  4. data/README.md +416 -132
  5. data/README1.md +267 -26
  6. data/docs/live_order_updates.md +319 -0
  7. data/docs/rails_websocket_integration.md +847 -0
  8. data/docs/standalone_ruby_websocket_integration.md +1588 -0
  9. data/docs/websocket_integration.md +871 -0
  10. data/examples/comprehensive_websocket_examples.rb +148 -0
  11. data/examples/instrument_finder_test.rb +195 -0
  12. data/examples/live_order_updates.rb +118 -0
  13. data/examples/market_depth_example.rb +144 -0
  14. data/examples/market_feed_example.rb +81 -0
  15. data/examples/order_update_example.rb +105 -0
  16. data/examples/trading_fields_example.rb +215 -0
  17. data/lib/DhanHQ/configuration.rb +16 -1
  18. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +103 -0
  19. data/lib/DhanHQ/contracts/trade_contract.rb +70 -0
  20. data/lib/DhanHQ/errors.rb +2 -0
  21. data/lib/DhanHQ/models/expired_options_data.rb +331 -0
  22. data/lib/DhanHQ/models/instrument.rb +96 -2
  23. data/lib/DhanHQ/models/order_update.rb +235 -0
  24. data/lib/DhanHQ/models/trade.rb +118 -31
  25. data/lib/DhanHQ/resources/expired_options_data.rb +22 -0
  26. data/lib/DhanHQ/version.rb +1 -1
  27. data/lib/DhanHQ/ws/base_connection.rb +249 -0
  28. data/lib/DhanHQ/ws/connection.rb +2 -2
  29. data/lib/DhanHQ/ws/decoder.rb +3 -3
  30. data/lib/DhanHQ/ws/market_depth/client.rb +376 -0
  31. data/lib/DhanHQ/ws/market_depth/decoder.rb +131 -0
  32. data/lib/DhanHQ/ws/market_depth.rb +74 -0
  33. data/lib/DhanHQ/ws/orders/client.rb +175 -11
  34. data/lib/DhanHQ/ws/orders/connection.rb +40 -81
  35. data/lib/DhanHQ/ws/orders.rb +28 -0
  36. data/lib/DhanHQ/ws/segments.rb +18 -2
  37. data/lib/DhanHQ/ws.rb +3 -2
  38. data/lib/dhan_hq.rb +5 -0
  39. metadata +35 -1
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require_relative "../base_connection"
5
+ require_relative "decoder"
6
+ require_relative "../../models/instrument"
7
+ require_relative "../../constants"
8
+
9
+ module DhanHQ
10
+ module WS
11
+ module MarketDepth
12
+ ##
13
+ # WebSocket client for Full Market Depth data
14
+ # Provides real-time market depth (bid/ask levels) for specified symbols
15
+ class Client < BaseConnection
16
+ SUBSCRIBE_REQUEST_CODE = 23
17
+ UNSUBSCRIBE_REQUEST_CODE = 12
18
+
19
+ ##
20
+ # Initialize Market Depth WebSocket client
21
+ # @param symbols [Array<String, Hash>] List of symbols (or metadata hashes) to subscribe to
22
+ # @param options [Hash] Connection options
23
+ def initialize(symbols: [], **options)
24
+ cfg = DhanHQ.configuration
25
+ url = options[:url] || build_market_depth_url(cfg)
26
+ super(url: url, **options)
27
+
28
+ @symbols = Array(symbols)
29
+ @subscriptions = Concurrent::Map.new
30
+ @instrument_cache = Concurrent::Map.new
31
+ @decoder = Decoder.new
32
+ end
33
+
34
+ ##
35
+ # Start the Market Depth WebSocket connection
36
+ # @return [Client] self for method chaining
37
+ def start
38
+ super
39
+ subscribe_to_symbols(@symbols) if @symbols.any?
40
+ self
41
+ end
42
+
43
+ ##
44
+ # Subscribe to market depth for specific symbols
45
+ # @param symbols [Array<String, Hash>] Symbols (or metadata hashes) to subscribe to
46
+ # @return [Client] self for method chaining
47
+ def subscribe(symbols)
48
+ subscribe_to_symbols(Array(symbols))
49
+ self
50
+ end
51
+
52
+ ##
53
+ # Unsubscribe from market depth for specific symbols
54
+ # @param symbols [Array<String, Hash>] Symbols (or metadata hashes) to unsubscribe from
55
+ # @return [Client] self for method chaining
56
+ def unsubscribe(symbols)
57
+ unsubscribe_from_symbols(Array(symbols))
58
+ self
59
+ end
60
+
61
+ ##
62
+ # Get current subscriptions
63
+ # @return [Array<String>] Currently subscribed symbol labels
64
+ def subscriptions
65
+ @subscriptions.values.map { |meta| meta[:original_label] }
66
+ end
67
+
68
+ ##
69
+ # Check if subscribed to a symbol
70
+ # @param symbol [String, Hash] Symbol or metadata hash to check
71
+ # @return [Boolean] true if subscribed
72
+ def subscribed?(symbol)
73
+ @subscriptions.key?(normalize_label(symbol))
74
+ end
75
+
76
+ private
77
+
78
+ ##
79
+ # Build Market Depth WebSocket URL
80
+ # @param config [Configuration] DhanHQ configuration
81
+ # @return [String] WebSocket URL
82
+ def build_market_depth_url(config)
83
+ token = config.access_token or raise "DhanHQ.access_token not set"
84
+ cid = config.client_id or raise "DhanHQ.client_id not set"
85
+ depth_level = config.market_depth_level || 20 # Default to 20 level depth
86
+
87
+ if depth_level == 200
88
+ "wss://full-depth-api.dhan.co/twohundreddepth?token=#{token}&clientId=#{cid}&authType=2"
89
+ else
90
+ "wss://depth-api-feed.dhan.co/twentydepth?token=#{token}&clientId=#{cid}&authType=2"
91
+ end
92
+ end
93
+
94
+ ##
95
+ # Run WebSocket session for Market Depth
96
+ # @return [Array<Boolean>] [failed, got_429]
97
+ def run_session
98
+ failed = false
99
+ got_429 = false
100
+
101
+ EM.run do
102
+ @ws = Faye::WebSocket::Client.new(@url, nil, headers: default_headers)
103
+
104
+ @ws.on :open do |_|
105
+ handle_open
106
+ end
107
+
108
+ @ws.on :message do |ev|
109
+ handle_message(ev)
110
+ end
111
+
112
+ @ws.on :close do |ev|
113
+ failed, got_429 = handle_close(ev)
114
+ EM.stop
115
+ end
116
+
117
+ @ws.on :error do |ev|
118
+ failed, got_429 = handle_error(ev)
119
+ EM.stop
120
+ end
121
+ end
122
+
123
+ [failed, got_429]
124
+ end
125
+
126
+ ##
127
+ # Process incoming WebSocket message
128
+ # @param data [String] Raw message data
129
+ def process_message(data)
130
+ depth_data = @decoder.decode(data)
131
+ return unless depth_data
132
+
133
+ emit(:depth_update, depth_data)
134
+ emit(:raw_depth, data) # For debugging
135
+ rescue StandardError => e
136
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth] Error processing message: #{e.class} #{e.message}")
137
+ emit(:error, e)
138
+ end
139
+
140
+ ##
141
+ # Subscribe to symbols
142
+ # @param symbols [Array<String, Hash>] Symbols to subscribe to
143
+ def subscribe_to_symbols(symbols)
144
+ symbols.each do |symbol|
145
+ resolution = resolve_symbol(symbol)
146
+ next unless resolution
147
+
148
+ label = resolution[:label]
149
+ next if @subscriptions.key?(label)
150
+
151
+ subscription_message = {
152
+ "RequestCode" => SUBSCRIBE_REQUEST_CODE,
153
+ "InstrumentCount" => 1,
154
+ "InstrumentList" => [
155
+ {
156
+ "ExchangeSegment" => resolution[:exchange_segment],
157
+ "SecurityId" => resolution[:security_id]
158
+ }
159
+ ]
160
+ }
161
+
162
+ send_message(subscription_message)
163
+ @subscriptions[label] = resolution
164
+ DhanHQ.logger&.info("[DhanHQ::WS::MarketDepth] Subscribed to #{resolution[:original_label]} (#{resolution[:exchange_segment]}:#{resolution[:security_id]})")
165
+ rescue StandardError => e
166
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth] Subscription error for #{symbol.inspect}: #{e.class} #{e.message}")
167
+ end
168
+ end
169
+
170
+ ##
171
+ # Unsubscribe from symbols
172
+ # @param symbols [Array<String, Hash>] Symbols to unsubscribe from
173
+ def unsubscribe_from_symbols(symbols)
174
+ symbols.each do |symbol|
175
+ label = normalize_label(symbol)
176
+ security_data = @subscriptions[label]
177
+ next unless security_data
178
+
179
+ unsubscribe_message = {
180
+ "RequestCode" => UNSUBSCRIBE_REQUEST_CODE,
181
+ "InstrumentCount" => 1,
182
+ "InstrumentList" => [
183
+ {
184
+ "ExchangeSegment" => security_data[:exchange_segment],
185
+ "SecurityId" => security_data[:security_id]
186
+ }
187
+ ]
188
+ }
189
+
190
+ send_message(unsubscribe_message)
191
+ @subscriptions.delete(label)
192
+ DhanHQ.logger&.info("[DhanHQ::WS::MarketDepth] Unsubscribed from #{security_data[:original_label]} (#{security_data[:exchange_segment]}:#{security_data[:security_id]})")
193
+ rescue StandardError => e
194
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth] Unsubscribe error for #{symbol.inspect}: #{e.class} #{e.message}")
195
+ end
196
+ end
197
+
198
+ ##
199
+ # Resolve symbol to security_id and exchange_segment using the instrument API
200
+ # @param symbol [String, Hash] Trading symbol or metadata hash
201
+ # @return [Hash, nil] Resolved instrument metadata
202
+ def resolve_symbol(symbol)
203
+ label = normalize_label(symbol)
204
+
205
+ return @subscriptions[label] if @subscriptions.key?(label)
206
+
207
+ from_hash = resolve_hash_symbol(symbol, label)
208
+ return from_hash if from_hash
209
+
210
+ symbol_str = symbol.to_s
211
+ segment_hint, symbol_code = extract_segment_and_symbol(symbol_str)
212
+ return nil unless symbol_code && !symbol_code.strip.empty?
213
+
214
+ instrument = find_instrument(symbol_code, segment_hint)
215
+ unless instrument
216
+ DhanHQ.logger&.warn("[DhanHQ::WS::MarketDepth] Unable to locate instrument for #{symbol_code} (segment hint: #{segment_hint || 'AUTO'})")
217
+ return nil
218
+ end
219
+
220
+ build_resolution(instrument, label, symbol_str)
221
+ end
222
+
223
+ def resolve_hash_symbol(symbol, label)
224
+ return nil unless symbol.is_a?(Hash)
225
+
226
+ exchange_segment = symbol[:exchange_segment] || symbol[:segment] || symbol[:exchange]
227
+ security_id = symbol[:security_id] || symbol[:token]
228
+ normalized_segment = exchange_segment&.to_s&.strip&.upcase
229
+
230
+ if security_id && normalized_segment
231
+ original_label = symbol[:symbol] || symbol[:symbol_name] || symbol[:name] || security_id
232
+ return {
233
+ label: label,
234
+ original_label: original_label.to_s,
235
+ exchange_segment: normalized_segment,
236
+ security_id: security_id.to_s,
237
+ display_name: symbol[:display_name],
238
+ symbol: original_label.to_s,
239
+ original_input: symbol
240
+ }
241
+ end
242
+
243
+ if security_id
244
+ instrument = find_instrument(security_id.to_s, normalized_segment)
245
+ return build_resolution(instrument, label, security_id) if instrument
246
+ end
247
+
248
+ symbol_code = symbol[:symbol] || symbol[:symbol_name] || symbol[:name]
249
+ return nil unless symbol_code
250
+
251
+ instrument = find_instrument(symbol_code.to_s, normalized_segment)
252
+ build_resolution(instrument, label, symbol_code) if instrument
253
+ end
254
+
255
+ def build_resolution(instrument, label, original_label)
256
+ return nil unless instrument
257
+
258
+ fallback_label = original_label.to_s.strip.empty? ? instrument.symbol_name.to_s : original_label.to_s
259
+
260
+ {
261
+ label: label,
262
+ original_label: fallback_label,
263
+ exchange_segment: instrument.exchange_segment.to_s,
264
+ security_id: instrument.security_id.to_s,
265
+ display_name: instrument.display_name,
266
+ symbol: instrument.symbol_name,
267
+ original_input: original_label
268
+ }
269
+ end
270
+
271
+ def extract_segment_and_symbol(symbol)
272
+ parts = symbol.to_s.split(":", 2).map(&:strip)
273
+ if parts.size == 2
274
+ [parts[0].upcase, parts[1]]
275
+ else
276
+ [nil, symbol.strip]
277
+ end
278
+ end
279
+
280
+ def normalize_label(symbol)
281
+ case symbol
282
+ when Hash
283
+ symbol_label_from_hash(symbol)
284
+ else
285
+ symbol.to_s.strip.upcase
286
+ end
287
+ end
288
+
289
+ def symbol_label_from_hash(symbol)
290
+ seg = symbol[:exchange_segment] || symbol[:segment] || symbol[:exchange]
291
+ sym = symbol[:symbol] || symbol[:symbol_name] || symbol[:name] || symbol[:security_id]
292
+ base_label = sym ? sym.to_s : symbol[:security_id].to_s
293
+ label = seg ? "#{seg}:#{base_label}" : base_label
294
+ label.strip.upcase
295
+ end
296
+
297
+ def find_instrument(symbol_code, segment_hint)
298
+ candidates = if segment_hint
299
+ [segment_hint.to_s.strip.upcase]
300
+ else
301
+ segment_priority
302
+ end
303
+
304
+ normalized_code = symbol_code.to_s.strip.upcase
305
+ candidates.each do |segment|
306
+ next if segment.nil? || segment.empty?
307
+
308
+ instrument = instrument_index(segment)[normalized_code]
309
+ return instrument if instrument
310
+
311
+ alt_code = normalized_code.gsub(/\s+/, " ")
312
+ instrument = instrument_index(segment)[alt_code]
313
+ return instrument if instrument
314
+ end
315
+
316
+ nil
317
+ end
318
+
319
+ def instrument_index(segment)
320
+ @instrument_cache.compute_if_absent(segment) do
321
+ build_instrument_index(segment)
322
+ end
323
+ rescue NoMethodError
324
+ # Concurrent::Map#compute_if_absent is available on recent versions.
325
+ # Fallback to fetch-or-store semantics if absent.
326
+ @instrument_cache[segment] ||= build_instrument_index(segment)
327
+ rescue StandardError => e
328
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth] Instrument index error for #{segment}: #{e.class} #{e.message}")
329
+ {}
330
+ end
331
+
332
+ def build_instrument_index(segment)
333
+ records = DhanHQ::Models::Instrument.by_segment(segment)
334
+ index = {}
335
+ records.each do |instrument|
336
+ keys_for(instrument).each do |key|
337
+ index[key] ||= instrument
338
+ end
339
+ end
340
+ index
341
+ rescue StandardError => e
342
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth] Failed to download instruments for #{segment}: #{e.class} #{e.message}")
343
+ {}
344
+ end
345
+
346
+ def keys_for(instrument)
347
+ keys = []
348
+ symbol_name = instrument.symbol_name.to_s.strip.upcase
349
+ display_name = instrument.display_name.to_s.strip.upcase
350
+ security_id = instrument.security_id.to_s.strip.upcase
351
+ keys << symbol_name unless symbol_name.empty?
352
+ keys << display_name unless display_name.empty? || display_name == symbol_name
353
+ keys << security_id unless security_id.empty?
354
+ if instrument.respond_to?(:series) && instrument.series
355
+ series_key = "#{symbol_name}:#{instrument.series}".strip.upcase
356
+ keys << series_key unless series_key.empty?
357
+ end
358
+ keys
359
+ end
360
+
361
+ def segment_priority
362
+ @segment_priority ||= begin
363
+ preferred = [
364
+ DhanHQ::Constants::NSE,
365
+ DhanHQ::Constants::BSE,
366
+ DhanHQ::Constants::NSE_FNO,
367
+ DhanHQ::Constants::BSE_FNO,
368
+ DhanHQ::Constants::INDEX
369
+ ]
370
+ (preferred + DhanHQ::Constants::EXCHANGE_SEGMENTS).compact.map(&:to_s).map(&:upcase).uniq
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module WS
5
+ module MarketDepth
6
+ ##
7
+ # Decoder for Market Depth WebSocket messages
8
+ # Handles parsing of market depth data (bid/ask levels)
9
+ class Decoder
10
+ ##
11
+ # Decode raw WebSocket message to market depth data
12
+ # @param data [String] Raw WebSocket message
13
+ # @return [Hash, nil] Parsed market depth data or nil if invalid
14
+ def decode(data)
15
+ return nil if data.nil? || data.empty?
16
+
17
+ begin
18
+ # Parse JSON message
19
+ message = JSON.parse(data)
20
+
21
+ # Handle different message types
22
+ case message["Type"]
23
+ when "depth_update"
24
+ parse_depth_update(message["Data"])
25
+ when "depth_snapshot"
26
+ parse_depth_snapshot(message["Data"])
27
+ else
28
+ # Unknown message type
29
+ nil
30
+ end
31
+ rescue JSON::ParserError => e
32
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth::Decoder] JSON parse error: #{e.message}")
33
+ nil
34
+ rescue StandardError => e
35
+ DhanHQ.logger&.error("[DhanHQ::WS::MarketDepth::Decoder] Decode error: #{e.class} #{e.message}")
36
+ nil
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ ##
43
+ # Parse depth update message
44
+ # @param data [Hash] Message data
45
+ # @return [Hash] Parsed depth update
46
+ def parse_depth_update(data)
47
+ {
48
+ type: :depth_update,
49
+ symbol: data["Symbol"],
50
+ exchange_segment: data["ExchangeSegment"],
51
+ security_id: data["SecurityId"],
52
+ timestamp: data["Timestamp"],
53
+ bids: parse_bid_levels(data["Bids"]),
54
+ asks: parse_ask_levels(data["Asks"]),
55
+ best_bid: data["BestBid"],
56
+ best_ask: data["BestAsk"],
57
+ spread: calculate_spread(data["BestBid"], data["BestAsk"]),
58
+ total_bid_qty: data["TotalBidQty"],
59
+ total_ask_qty: data["TotalAskQty"]
60
+ }
61
+ end
62
+
63
+ ##
64
+ # Parse depth snapshot message
65
+ # @param data [Hash] Message data
66
+ # @return [Hash] Parsed depth snapshot
67
+ def parse_depth_snapshot(data)
68
+ {
69
+ type: :depth_snapshot,
70
+ symbol: data["Symbol"],
71
+ exchange_segment: data["ExchangeSegment"],
72
+ security_id: data["SecurityId"],
73
+ timestamp: data["Timestamp"],
74
+ bids: parse_bid_levels(data["Bids"]),
75
+ asks: parse_ask_levels(data["Asks"]),
76
+ best_bid: data["BestBid"],
77
+ best_ask: data["BestAsk"],
78
+ spread: calculate_spread(data["BestBid"], data["BestAsk"]),
79
+ total_bid_qty: data["TotalBidQty"],
80
+ total_ask_qty: data["TotalAskQty"]
81
+ }
82
+ end
83
+
84
+ ##
85
+ # Parse bid levels
86
+ # @param bids [Array] Raw bid levels
87
+ # @return [Array<Hash>] Parsed bid levels
88
+ def parse_bid_levels(bids)
89
+ return [] unless bids.is_a?(Array)
90
+
91
+ bids.map do |bid|
92
+ {
93
+ price: bid["Price"].to_f,
94
+ quantity: bid["Quantity"].to_i,
95
+ orders: bid["Orders"] || 1
96
+ }
97
+ end
98
+ end
99
+
100
+ ##
101
+ # Parse ask levels
102
+ # @param asks [Array] Raw ask levels
103
+ # @return [Array<Hash>] Parsed ask levels
104
+ def parse_ask_levels(asks)
105
+ return [] unless asks.is_a?(Array)
106
+
107
+ asks.map do |ask|
108
+ {
109
+ price: ask["Price"].to_f,
110
+ quantity: ask["Quantity"].to_i,
111
+ orders: ask["Orders"] || 1
112
+ }
113
+ end
114
+ end
115
+
116
+ ##
117
+ # Calculate spread between best bid and ask
118
+ # @param best_bid [Float, String] Best bid price
119
+ # @param best_ask [Float, String] Best ask price
120
+ # @return [Float] Spread amount
121
+ def calculate_spread(best_bid, best_ask)
122
+ bid = best_bid.to_f
123
+ ask = best_ask.to_f
124
+ return 0.0 if bid.zero? || ask.zero?
125
+
126
+ ask - bid
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "market_depth/client"
4
+
5
+ module DhanHQ
6
+ module WS
7
+ module MarketDepth
8
+ ##
9
+ # Market Depth WebSocket module for real-time market depth data
10
+ # Provides access to bid/ask levels and order book depth
11
+
12
+ module_function
13
+
14
+ ##
15
+ # Connect to Market Depth WebSocket with a simple callback
16
+ # @param symbols [Array<String>] Symbols to subscribe to
17
+ # @param options [Hash] Connection options
18
+ # @param block [Proc] Callback for depth updates
19
+ # @return [Client] WebSocket client instance
20
+ def connect(symbols: [], **options, &block)
21
+ client = Client.new(symbols: symbols, **options)
22
+ client.on(:depth_update, &block) if block_given?
23
+ client.start
24
+ end
25
+
26
+ ##
27
+ # Create a new Market Depth client with advanced features
28
+ # @param symbols [Array<String>] Symbols to subscribe to
29
+ # @param options [Hash] Connection options
30
+ # @return [Client] New client instance
31
+ def client(symbols: [], **options)
32
+ Client.new(symbols: symbols, **options)
33
+ end
34
+
35
+ ##
36
+ # Quick connection with multiple event handlers
37
+ # @param symbols [Array<String>] Symbols to subscribe to
38
+ # @param handlers [Hash] Event handlers
39
+ # @param options [Hash] Connection options
40
+ # @return [Client] Started client instance
41
+ def connect_with_handlers(symbols: [], handlers: {}, **options)
42
+ client = Client.new(symbols: symbols, **options).start
43
+
44
+ handlers.each do |event, handler|
45
+ client.on(event, &handler)
46
+ end
47
+
48
+ client
49
+ end
50
+
51
+ ##
52
+ # Subscribe to market depth for specific symbols
53
+ # @param symbols [Array<String>] Symbols to subscribe to
54
+ # @param options [Hash] Connection options
55
+ # @param block [Proc] Callback for depth updates
56
+ # @return [Client] Started client instance
57
+ def subscribe(symbols:, **options, &block)
58
+ connect(symbols: symbols, **options, &block)
59
+ end
60
+
61
+ ##
62
+ # Get market depth snapshot for symbols
63
+ # @param symbols [Array<String>] Symbols to get snapshot for
64
+ # @param options [Hash] Connection options
65
+ # @param block [Proc] Callback for snapshot data
66
+ # @return [Client] Started client instance
67
+ def snapshot(symbols:, **options, &block)
68
+ client = Client.new(symbols: symbols, **options)
69
+ client.on(:depth_snapshot, &block) if block_given?
70
+ client.start
71
+ end
72
+ end
73
+ end
74
+ end