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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Event-driven architecture for the trading system.
5
+ #
6
+ # Provides pub/sub event bus, typed events, and async helpers.
7
+ #
8
+ # @example Subscribe to events
9
+ # DhanHQ::Events.on(:order_filled) do |event|
10
+ # puts "Order filled: #{event.order_id}"
11
+ # end
12
+ #
13
+ # @example Emit events
14
+ # DhanHQ::Events.emit(:order_filled, order_id: "123", filled_quantity: 10)
15
+ #
16
+ module Events
17
+ # Simple pub/sub event bus.
18
+ #
19
+ # Supports subscribing to specific event types or all events.
20
+ class Bus
21
+ def initialize
22
+ @subscribers = Hash.new { |h, k| h[k] = [] }
23
+ @global_subscribers = []
24
+ end
25
+
26
+ # Subscribe to an event type.
27
+ #
28
+ # @param event_type [Symbol, Class] Event type to subscribe to
29
+ # @param block [Proc] Handler block
30
+ # @return [Integer] Subscription ID for unsubscribing
31
+ def on(event_type, &block)
32
+ @subscribers[event_type] << block
33
+ @subscribers[event_type].length - 1
34
+ end
35
+
36
+ # Subscribe to all events.
37
+ #
38
+ # @param block [Proc] Handler block
39
+ # @return [Integer] Subscription ID
40
+ def subscribe_all(&block)
41
+ @global_subscribers << block
42
+ @global_subscribers.length - 1
43
+ end
44
+
45
+ # Unsubscribe from an event type.
46
+ #
47
+ # @param event_type [Symbol, Class] Event type
48
+ # @param subscription_id [Integer] Subscription ID from on()
49
+ # @return [Boolean] True if unsubscribed
50
+ # rubocop:disable Naming/PredicateMethod
51
+ def off(event_type, subscription_id)
52
+ return false unless @subscribers[event_type]
53
+
54
+ @subscribers[event_type].delete_at(subscription_id)
55
+ true
56
+ end
57
+
58
+ # Unsubscribe from all events.
59
+ #
60
+ # @param subscription_id [Integer] Subscription ID
61
+ # @return [Boolean] True if unsubscribed
62
+ def unsubscribe_all(subscription_id)
63
+ @global_subscribers.delete_at(subscription_id)
64
+ true
65
+ end
66
+ # rubocop:enable Naming/PredicateMethod
67
+
68
+ # Emit an event to all subscribers.
69
+ #
70
+ # @param event_type [Symbol, Class] Event type
71
+ # @param data [Hash] Event data
72
+ # @return [void]
73
+ def emit(event_type, data = {})
74
+ event = build_event(event_type, data)
75
+
76
+ # Notify type-specific subscribers
77
+ @subscribers[event_type]&.each do |handler|
78
+ handler.call(event)
79
+ rescue StandardError => e
80
+ DhanHQ.logger&.error("[Events] Error in handler for #{event_type}: #{e.message}")
81
+ end
82
+
83
+ # Notify global subscribers
84
+ @global_subscribers.each do |handler|
85
+ handler.call(event_type, event)
86
+ rescue StandardError => e
87
+ DhanHQ.logger&.error("[Events] Error in global handler: #{e.message}")
88
+ end
89
+ end
90
+
91
+ # Get count of subscribers for an event type.
92
+ #
93
+ # @param event_type [Symbol] Event type
94
+ # @return [Integer]
95
+ def subscriber_count(event_type)
96
+ @subscribers[event_type]&.length || 0
97
+ end
98
+
99
+ # Clear all subscribers.
100
+ def clear
101
+ @subscribers.clear
102
+ @global_subscribers.clear
103
+ end
104
+
105
+ private
106
+
107
+ def build_event(event_type, data)
108
+ case event_type
109
+ when :order_placed then OrderPlaced.new(data)
110
+ when :order_filled then OrderFilled.new(data)
111
+ when :order_cancelled then OrderCancelled.new(data)
112
+ when :order_rejected then OrderRejected.new(data)
113
+ when :sl_hit then SLHit.new(data)
114
+ when :tp_hit then TPHit.new(data)
115
+ when :tick_updated then TickUpdated.new(data)
116
+ when :position_opened then PositionOpened.new(data)
117
+ when :position_closed then PositionClosed.new(data)
118
+ when :strategy_signal then StrategySignal.new(data)
119
+ else
120
+ Base.new(data.merge(event_type: event_type))
121
+ end
122
+ end
123
+ end
124
+
125
+ # Default bus instance
126
+ @bus = Bus.new
127
+
128
+ class << self
129
+ # Get the default event bus.
130
+ attr_reader :bus
131
+
132
+ # Subscribe to an event type (delegates to bus).
133
+ def on(event_type, &)
134
+ bus.on(event_type, &)
135
+ end
136
+
137
+ # Subscribe to all events (delegates to bus).
138
+ def subscribe_all(&)
139
+ bus.subscribe_all(&)
140
+ end
141
+
142
+ # Emit an event (delegates to bus).
143
+ def emit(event_type, data = {})
144
+ bus.emit(event_type, data)
145
+ end
146
+
147
+ # Unsubscribe from an event type (delegates to bus).
148
+ def off(event_type, subscription_id)
149
+ bus.off(event_type, subscription_id)
150
+ end
151
+
152
+ # Clear all subscribers (delegates to bus).
153
+ def clear
154
+ bus.clear
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "events/base"
4
+ require_relative "events/bus"
5
+
6
+ module DhanHQ
7
+ # Event-driven architecture for the trading system.
8
+ #
9
+ # Provides typed events, pub/sub event bus, and async helpers
10
+ # for building reactive trading applications.
11
+ #
12
+ # @example Subscribe to order events
13
+ # DhanHQ::Events.on(:order_filled) do |event|
14
+ # puts "Order filled: #{event.order_id}"
15
+ # puts "Filled quantity: #{event.filled_quantity}"
16
+ # end
17
+ #
18
+ # @example Emit events
19
+ # DhanHQ::Events.emit(:order_filled, order_id: "123", filled_quantity: 10)
20
+ #
21
+ # @example Subscribe to all events
22
+ # DhanHQ::Events.subscribe_all do |event_type, event|
23
+ # puts "Event: #{event_type} - #{event}"
24
+ # end
25
+ #
26
+ # Available event types:
27
+ # - :order_placed - Order placed successfully
28
+ # - :order_filled - Order fully or partially filled
29
+ # - :order_cancelled - Order cancelled
30
+ # - :order_rejected - Order rejected by exchange
31
+ # - :sl_hit - Stop loss triggered
32
+ # - :tp_hit - Take profit triggered
33
+ # - :tick_updated - New market tick received
34
+ # - :position_opened - New position opened
35
+ # - :position_closed - Position closed
36
+ # - :strategy_signal - Strategy generated a signal
37
+ #
38
+ module Events
39
+ end
40
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Technical analysis indicators for market data analysis.
5
+ module Indicators
6
+ # Simple Moving Average (SMA)
7
+ #
8
+ # Calculates the average of a specified number of data points.
9
+ #
10
+ # @example Calculate 20-period SMA
11
+ # closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
12
+ # sma = DhanHQ::Indicators::SMA.calculate(closes, period: 5)
13
+ # #=> [102.2, 103.0, 103.8, 104.6, 105.4, 106.8, 108.2, 107.8]
14
+ #
15
+ class SMA
16
+ # Calculate SMA for the given data.
17
+ #
18
+ # @param data [Array<Numeric>] Input price data
19
+ # @param period [Integer] Number of periods (default: 20)
20
+ # @return [Array<Float>] SMA values (nil for insufficient data points)
21
+ def self.calculate(data, period: 20)
22
+ return [] if data.nil? || data.empty? || period < 1
23
+
24
+ data.each_index.map do |i|
25
+ next nil if i < period - 1
26
+
27
+ window = data[(i - period + 1)..i]
28
+ window.sum.to_f / period
29
+ end
30
+ end
31
+ end
32
+
33
+ # Exponential Moving Average (EMA)
34
+ #
35
+ # Weighted average that gives more importance to recent prices.
36
+ #
37
+ # @example Calculate 12-period EMA
38
+ # closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
39
+ # ema = DhanHQ::Indicators::EMA.calculate(closes, period: 5)
40
+ #
41
+ class EMA
42
+ # Calculate EMA for the given data.
43
+ #
44
+ # @param data [Array<Numeric>] Input price data
45
+ # @param period [Integer] Number of periods (default: 20)
46
+ # @return [Array<Float>] EMA values (nil for insufficient data points)
47
+ def self.calculate(data, period: 20)
48
+ return [] if data.nil? || data.empty? || period < 1
49
+
50
+ multiplier = 2.0 / (period + 1)
51
+ ema_values = []
52
+
53
+ data.each_with_index do |price, i|
54
+ if i < period - 1
55
+ ema_values << nil
56
+ elsif i == period - 1
57
+ # First EMA is SMA
58
+ window = data[0..i]
59
+ ema_values << (window.sum.to_f / period)
60
+ else
61
+ # EMA = (Price - Previous EMA) * Multiplier + Previous EMA
62
+ previous_ema = ema_values.last
63
+ ema_values << (((price - previous_ema) * multiplier) + previous_ema)
64
+ end
65
+ end
66
+
67
+ ema_values
68
+ end
69
+ end
70
+
71
+ # Relative Strength Index (RSI)
72
+ #
73
+ # Momentum oscillator measuring speed and magnitude of price changes.
74
+ #
75
+ # @example Calculate 14-period RSI
76
+ # closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109, 111, 110, 112, 114, 113]
77
+ # rsi = DhanHQ::Indicators::RSI.calculate(closes, period: 14)
78
+ #
79
+ class RSI
80
+ # Calculate RSI for the given data.
81
+ #
82
+ # @param data [Array<Numeric>] Input price data
83
+ # @param period [Integer] Number of periods (default: 14)
84
+ # @return [Array<Float>] RSI values (nil for insufficient data points)
85
+ def self.calculate(data, period: 14)
86
+ return [] if data.nil? || data.empty? || period < 1
87
+ return [] if data.length < period + 1
88
+
89
+ # Calculate price changes
90
+ changes = data.each_cons(2).map { |a, b| b - a }
91
+
92
+ # Calculate initial average gains and losses
93
+ gains = changes[0...period].select(&:positive?)
94
+ losses = changes[0...period].select(&:negative?).map(&:abs)
95
+
96
+ avg_gain = gains.sum.to_f / period
97
+ avg_loss = losses.sum.to_f / period
98
+
99
+ rsi_values = Array.new(period, nil)
100
+
101
+ # Calculate RSI for first period
102
+ rsi_values << calculate_rsi(avg_gain, avg_loss)
103
+
104
+ # Calculate subsequent RSI values
105
+ changes[period..].each do |change|
106
+ gain = change.positive? ? change : 0
107
+ loss = change.negative? ? change.abs : 0
108
+
109
+ avg_gain = ((avg_gain * (period - 1)) + gain) / period
110
+ avg_loss = ((avg_loss * (period - 1)) + loss) / period
111
+
112
+ rsi_values << calculate_rsi(avg_gain, avg_loss)
113
+ end
114
+
115
+ rsi_values
116
+ end
117
+
118
+ def self.calculate_rsi(avg_gain, avg_loss)
119
+ return 100.0 if avg_loss.zero?
120
+ return 0.0 if avg_gain.zero?
121
+
122
+ rs = avg_gain / avg_loss
123
+ 100 - (100 / (1 + rs))
124
+ end
125
+ private_class_method :calculate_rsi
126
+ end
127
+
128
+ # Moving Average Convergence Divergence (MACD)
129
+ #
130
+ # Trend-following momentum indicator showing relationship between two EMAs.
131
+ #
132
+ # @example Calculate MACD
133
+ # closes = (0..99).map { |i| 100 + Math.sin(i * 0.1) * 10 }
134
+ # macd = DhanHQ::Indicators::MACD.calculate(closes)
135
+ # #=> { macd_line: [...], signal_line: [...], histogram: [...] }
136
+ #
137
+ class MACD
138
+ # Calculate MACD for the given data.
139
+ #
140
+ # @param data [Array<Numeric>] Input price data
141
+ # @param fast_period [Integer] Fast EMA period (default: 12)
142
+ # @param slow_period [Integer] Slow EMA period (default: 26)
143
+ # @param signal_period [Integer] Signal line period (default: 9)
144
+ # @return [Hash] Hash with :macd_line, :signal_line, and :histogram arrays
145
+ def self.calculate(data, fast_period: 12, slow_period: 26, signal_period: 9)
146
+ return { macd_line: [], signal_line: [], histogram: [] } if data.nil? || data.empty?
147
+
148
+ # Calculate fast and slow EMAs
149
+ fast_ema = EMA.calculate(data, period: fast_period)
150
+ slow_ema = EMA.calculate(data, period: slow_period)
151
+
152
+ # Calculate MACD line (fast EMA - slow EMA)
153
+ macd_line = fast_ema.zip(slow_ema).map do |fast, slow|
154
+ next nil if fast.nil? || slow.nil?
155
+
156
+ fast - slow
157
+ end
158
+
159
+ # Calculate signal line (EMA of MACD line)
160
+ valid_macd = macd_line.compact
161
+ signal_line_raw = valid_macd.empty? ? [] : EMA.calculate(valid_macd, period: signal_period)
162
+
163
+ # Align signal line with MACD line
164
+ signal_line = Array.new(macd_line.length - signal_line_raw.length, nil) + signal_line_raw
165
+
166
+ # Calculate histogram (MACD line - signal line)
167
+ histogram = macd_line.zip(signal_line).map do |macd, signal|
168
+ next nil if macd.nil? || signal.nil?
169
+
170
+ macd - signal
171
+ end
172
+
173
+ {
174
+ macd_line: macd_line,
175
+ signal_line: signal_line,
176
+ histogram: histogram
177
+ }
178
+ end
179
+ end
180
+
181
+ # Bollinger Bands
182
+ #
183
+ # Volatility indicator consisting of three lines: middle band (SMA), upper band, lower band.
184
+ #
185
+ # @example Calculate Bollinger Bands
186
+ # closes = (0..99).map { |i| 100 + Math.sin(i * 0.1) * 10 }
187
+ # bb = DhanHQ::Indicators::BollingerBands.calculate(closes)
188
+ # #=> { upper: [...], middle: [...], lower: [...] }
189
+ #
190
+ class BollingerBands
191
+ # Calculate Bollinger Bands for the given data.
192
+ #
193
+ # @param data [Array<Numeric>] Input price data
194
+ # @param period [Integer] Number of periods (default: 20)
195
+ # @param std_dev [Float] Number of standard deviations (default: 2.0)
196
+ # @return [Hash] Hash with :upper, :middle, and :lower arrays
197
+ def self.calculate(data, period: 20, std_dev: 2.0)
198
+ return { upper: [], middle: [], lower: [] } if data.nil? || data.empty?
199
+
200
+ # Calculate middle band (SMA)
201
+ middle = SMA.calculate(data, period: period)
202
+
203
+ # Calculate upper and lower bands
204
+ upper = []
205
+ lower = []
206
+
207
+ data.each_with_index do |_, i|
208
+ if i < period - 1
209
+ upper << nil
210
+ lower << nil
211
+ else
212
+ window = data[(i - period + 1)..i]
213
+ mean = middle[i]
214
+ variance = window.sum { |x| (x - mean)**2 } / period
215
+ std = Math.sqrt(variance)
216
+
217
+ upper << (mean + (std_dev * std))
218
+ lower << (mean - (std_dev * std))
219
+ end
220
+ end
221
+
222
+ {
223
+ upper: upper,
224
+ middle: middle,
225
+ lower: lower
226
+ }
227
+ end
228
+ end
229
+
230
+ # Average True Range (ATR)
231
+ #
232
+ # Volatility indicator measuring market volatility.
233
+ #
234
+ # @example Calculate ATR
235
+ # ohlc = [
236
+ # { open: 100, high: 105, low: 98, close: 103 },
237
+ # { open: 103, high: 108, low: 101, close: 106 },
238
+ # ...
239
+ # ]
240
+ # atr = DhanHQ::Indicators::ATR.calculate(ohlc, period: 14)
241
+ #
242
+ class ATR
243
+ # Calculate ATR for the given OHLC data.
244
+ #
245
+ # @param data [Array<Hash>] Array of OHLC hashes with :open, :high, :low, :close
246
+ # @param period [Integer] Number of periods (default: 14)
247
+ # @return [Array<Float>] ATR values (nil for insufficient data points)
248
+ def self.calculate(data, period: 14)
249
+ return [] if data.nil? || data.empty? || period < 1
250
+ return [] if data.length < 2
251
+
252
+ # Calculate true ranges
253
+ true_ranges = data.each_cons(2).map do |prev, curr|
254
+ high = curr[:high] || curr["high"]
255
+ low = curr[:low] || curr["low"]
256
+ prev_close = prev[:close] || prev["close"]
257
+
258
+ [high - low, (high - prev_close).abs, (low - prev_close).abs].max
259
+ end
260
+
261
+ # Calculate ATR using smoothed average
262
+ atr_values = [nil] # First value has no true range
263
+
264
+ # First ATR is simple average
265
+ if true_ranges.length >= period
266
+ first_atr = true_ranges[0...period].sum.to_f / period
267
+ atr_values.concat(Array.new(period - 1, nil))
268
+ atr_values << first_atr
269
+
270
+ # Subsequent ATRs use smoothing
271
+ true_ranges[period..].each do |tr|
272
+ previous_atr = atr_values.last
273
+ atr_values << (((previous_atr * (period - 1)) + tr) / period)
274
+ end
275
+ else
276
+ atr_values.concat(Array.new(true_ranges.length, nil))
277
+ end
278
+
279
+ atr_values
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Higher-level abstractions for market data consumption.
5
+ module MarketData
6
+ # A snapshot of market data for multiple instruments at a point in time.
7
+ #
8
+ # Wraps the raw MarketFeed response into a more convenient structure
9
+ # with typed accessors and helper methods.
10
+ #
11
+ # @example Build a snapshot from MarketFeed response
12
+ # response = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [11536, 3456])
13
+ # snapshot = DhanHQ::MarketData::MarketSnapshot.from_response(response)
14
+ # snapshot.ltp("NSE_EQ", "11536") #=> 2850.50
15
+ #
16
+ class MarketSnapshot
17
+ attr_reader :data, :fetched_at
18
+
19
+ def initialize(data = {})
20
+ @data = data
21
+ @fetched_at = Time.now
22
+ end
23
+
24
+ # Build a MarketSnapshot from a raw MarketFeed API response.
25
+ def self.from_response(response)
26
+ raw_data = response.is_a?(Hash) ? (response[:data] || response["data"] || {}) : {}
27
+ new(normalize_data(raw_data))
28
+ end
29
+
30
+ # Get LTP for a specific instrument.
31
+ def ltp(exchange_segment, security_id)
32
+ instrument_data(exchange_segment, security_id)&.dig(:last_price)
33
+ end
34
+
35
+ # Get OHLC for a specific instrument.
36
+ def ohlc(exchange_segment, security_id)
37
+ instrument_data(exchange_segment, security_id)&.dig(:ohlc)
38
+ end
39
+
40
+ # Get full quote (market depth) for a specific instrument.
41
+ def quote(exchange_segment, security_id)
42
+ instrument_data(exchange_segment, security_id)
43
+ end
44
+
45
+ # Get all instruments for a specific exchange segment.
46
+ def for_segment(exchange_segment)
47
+ @data[exchange_segment.to_s] || {}
48
+ end
49
+
50
+ # Get all security IDs across all segments.
51
+ def security_ids
52
+ @data.each_with_object([]) do |(_segment, instruments), ids|
53
+ ids.concat(instruments.keys)
54
+ end
55
+ end
56
+
57
+ # Get total number of instruments in the snapshot.
58
+ def size
59
+ @data.values.sum(&:size)
60
+ end
61
+
62
+ # Check if the snapshot is empty.
63
+ def empty?
64
+ @data.empty? || @data.values.all?(&:empty?)
65
+ end
66
+
67
+ private
68
+
69
+ def instrument_data(exchange_segment, security_id)
70
+ @data[exchange_segment.to_s]&.dig(security_id.to_s)
71
+ end
72
+
73
+ def self.normalize_data(data)
74
+ result = {}
75
+ data.each do |segment, instruments|
76
+ result[segment.to_s] = {}
77
+ next unless instruments.is_a?(Hash)
78
+
79
+ instruments.each do |sec_id, info|
80
+ result[segment.to_s][sec_id.to_s] = normalize_instrument(info)
81
+ end
82
+ end
83
+ result
84
+ end
85
+ private_class_method :normalize_data
86
+
87
+ def self.normalize_instrument(info)
88
+ return info unless info.is_a?(Hash)
89
+
90
+ info.each_with_object({}) do |(key, value), hash|
91
+ hash[key.to_sym] = value
92
+ end
93
+ end
94
+ private_class_method :normalize_instrument
95
+ end
96
+ end
97
+ end