ollama-client 0.2.1 → 0.2.3

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +220 -12
  4. data/docs/CLOUD.md +29 -0
  5. data/docs/CONSOLE_IMPROVEMENTS.md +256 -0
  6. data/docs/FEATURES_ADDED.md +145 -0
  7. data/docs/HANDLERS_ANALYSIS.md +190 -0
  8. data/docs/README.md +37 -0
  9. data/docs/SCHEMA_FIXES.md +147 -0
  10. data/docs/TEST_UPDATES.md +107 -0
  11. data/examples/README.md +92 -0
  12. data/examples/advanced_complex_schemas.rb +6 -3
  13. data/examples/advanced_multi_step_agent.rb +13 -7
  14. data/examples/chat_console.rb +143 -0
  15. data/examples/complete_workflow.rb +14 -4
  16. data/examples/dhan_console.rb +843 -0
  17. data/examples/dhanhq/agents/base_agent.rb +0 -2
  18. data/examples/dhanhq/agents/orchestrator_agent.rb +1 -2
  19. data/examples/dhanhq/agents/technical_analysis_agent.rb +67 -49
  20. data/examples/dhanhq/analysis/market_structure.rb +44 -28
  21. data/examples/dhanhq/analysis/pattern_recognizer.rb +64 -47
  22. data/examples/dhanhq/analysis/trend_analyzer.rb +6 -8
  23. data/examples/dhanhq/dhanhq_agent.rb +296 -99
  24. data/examples/dhanhq/indicators/technical_indicators.rb +3 -5
  25. data/examples/dhanhq/scanners/intraday_options_scanner.rb +360 -255
  26. data/examples/dhanhq/scanners/swing_scanner.rb +118 -84
  27. data/examples/dhanhq/schemas/agent_schemas.rb +2 -2
  28. data/examples/dhanhq/services/data_service.rb +5 -7
  29. data/examples/dhanhq/services/trading_service.rb +0 -3
  30. data/examples/dhanhq/technical_analysis_agentic_runner.rb +217 -84
  31. data/examples/dhanhq/technical_analysis_runner.rb +216 -162
  32. data/examples/dhanhq/test_tool_calling.rb +538 -0
  33. data/examples/dhanhq/test_tool_calling_verbose.rb +251 -0
  34. data/examples/dhanhq/utils/trading_parameter_normalizer.rb +12 -17
  35. data/examples/dhanhq_agent.rb +159 -116
  36. data/examples/dhanhq_tools.rb +1158 -251
  37. data/examples/multi_step_agent_with_external_data.rb +368 -0
  38. data/examples/structured_tools.rb +89 -0
  39. data/examples/test_dhanhq_tool_calling.rb +375 -0
  40. data/examples/test_tool_calling.rb +160 -0
  41. data/examples/tool_calling_direct.rb +124 -0
  42. data/examples/tool_dto_example.rb +94 -0
  43. data/exe/dhan_console +4 -0
  44. data/exe/ollama-client +1 -1
  45. data/lib/ollama/agent/executor.rb +116 -15
  46. data/lib/ollama/client.rb +118 -55
  47. data/lib/ollama/config.rb +36 -0
  48. data/lib/ollama/dto.rb +187 -0
  49. data/lib/ollama/embeddings.rb +77 -0
  50. data/lib/ollama/options.rb +104 -0
  51. data/lib/ollama/response.rb +121 -0
  52. data/lib/ollama/tool/function/parameters/property.rb +72 -0
  53. data/lib/ollama/tool/function/parameters.rb +101 -0
  54. data/lib/ollama/tool/function.rb +78 -0
  55. data/lib/ollama/tool.rb +60 -0
  56. data/lib/ollama/version.rb +1 -1
  57. data/lib/ollama_client.rb +3 -0
  58. metadata +31 -3
  59. /data/{PRODUCTION_FIXES.md → docs/PRODUCTION_FIXES.md} +0 -0
  60. /data/{TESTING.md → docs/TESTING.md} +0 -0
@@ -10,6 +10,7 @@
10
10
  require "json"
11
11
  require "date"
12
12
  require "dhan_hq"
13
+ require_relative "dhanhq/indicators/technical_indicators"
13
14
 
14
15
  # Helper to get valid exchange segments from DhanHQ constants
15
16
  def valid_exchange_segments
@@ -53,6 +54,45 @@ rescue StandardError
53
54
  nil
54
55
  end
55
56
 
57
+ # Helper to validate security_id is numeric (integer or numeric string), not a symbol string
58
+ # Returns [is_valid, result_or_error_message]
59
+ # If valid: [true, integer_value]
60
+ # If invalid: [false, error_message_string]
61
+ def validate_security_id_numeric(security_id)
62
+ return [false, "security_id cannot be nil"] if security_id.nil?
63
+
64
+ # If it's already an integer, it's valid
65
+ return [true, security_id.to_i] if security_id.is_a?(Integer)
66
+
67
+ # If it's a string, check if it's numeric
68
+ if security_id.is_a?(String)
69
+ # Remove whitespace
70
+ cleaned = security_id.strip
71
+
72
+ # Check if it's a numeric string (all digits, possibly with leading/trailing spaces)
73
+ return [true, cleaned.to_i] if cleaned.match?(/^\d+$/)
74
+
75
+ # It's a non-numeric string (likely a symbol like "NIFTY")
76
+ message = "security_id must be numeric (integer or numeric string like '13'), " \
77
+ "not a symbol string like '#{cleaned}'. Use symbol parameter for symbols."
78
+ return [false, message]
79
+
80
+ end
81
+
82
+ # Try to convert to integer
83
+ begin
84
+ int_value = security_id.to_i
85
+ # Check if conversion was successful (to_i returns 0 for non-numeric strings)
86
+ if int_value.zero? && security_id.to_s.strip != "0"
87
+ return [false, "security_id must be numeric (integer or numeric string), got: #{security_id.inspect}"]
88
+ end
89
+
90
+ [true, int_value]
91
+ rescue StandardError
92
+ [false, "security_id must be numeric (integer or numeric string), got: #{security_id.inspect}"]
93
+ end
94
+ end
95
+
56
96
  # Debug logging helper (if needed)
57
97
  def debug_log(location, message, data = {}, hypothesis_id = nil)
58
98
  log_entry = {
@@ -79,6 +119,13 @@ end
79
119
  # 4. Historical Data
80
120
  # 5. Expired Options Data
81
121
  # 6. Option Chain
122
+ #
123
+ # NOTE: These tools are callable functions. For use with Ollama::Agent::Executor,
124
+ # you can either:
125
+ # 1. Use them directly as callables (auto-inferred schema)
126
+ # 2. Wrap them with structured Ollama::Tool classes for explicit schemas
127
+ # See examples/dhanhq/technical_analysis_agentic_runner.rb for examples
128
+ # of structured Tool definitions with type safety and better LLM understanding.
82
129
  class DhanHQDataTools
83
130
  class << self
84
131
  # Rate limiting: MarketFeed APIs have a limit of 1 request per second
@@ -98,145 +145,221 @@ class DhanHQDataTools
98
145
  end
99
146
  end
100
147
 
101
- # 1. Market Quote API - Get market quote using Instrument convenience method
102
- # Uses instrument.quote which automatically uses instrument's security_id,
103
- # exchange_segment, and instrument attributes
104
- # Note: Instrument.find(exchange_segment, symbol) expects symbol
105
- # (e.g., "NIFTY", "RELIANCE"), not security_id
106
- # Rate limit: 1 request per second
107
- def get_market_quote(exchange_segment:, security_id: nil, symbol: nil)
108
- # Instrument.find expects symbol, support both for backward compatibility
109
- instrument_symbol = symbol || security_id
110
- unless instrument_symbol
148
+ # 0. Find Instrument - Search for instrument by symbol across common exchange segments
149
+ #
150
+ # REQUIRED PARAMETERS:
151
+ # - symbol [String]: Trading symbol to search for (e.g., "NIFTY", "RELIANCE", "TCS")
152
+ #
153
+ # Returns: Instrument details including:
154
+ # - exchange_segment: Exchange and segment identifier (e.g., "IDX_I", "NSE_EQ")
155
+ # - security_id: Numeric security ID (use this for subsequent API calls)
156
+ # - trading_symbol: Trading symbol
157
+ # - instrument_type: Type of instrument (EQUITY, INDEX, etc.)
158
+ # - name: Full name of the instrument
159
+ # - isin: ISIN code if available
160
+ #
161
+ # This is useful when you only have a symbol and need to resolve it to proper parameters
162
+ # Searches across common segments: NSE_EQ, BSE_EQ, NSE_FNO, BSE_FNO, IDX_I, NSE_CURRENCY, BSE_CURRENCY, MCX_COMM
163
+ def find_instrument(symbol:)
164
+ unless symbol
111
165
  return {
112
- action: "get_market_quote",
113
- error: "Either symbol or security_id must be provided",
114
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
166
+ action: "find_instrument",
167
+ error: "Symbol is required",
168
+ params: { symbol: symbol }
115
169
  }
116
170
  end
117
171
 
118
- rate_limit_marketfeed # Enforce rate limiting
119
- instrument_symbol = instrument_symbol.to_s
120
- exchange_segment = exchange_segment.to_s
172
+ symbol_str = symbol.to_s.upcase
173
+ common_index_symbols = %w[NIFTY BANKNIFTY FINNIFTY MIDCPNIFTY SENSEX BANKEX]
121
174
 
122
- # Find instrument first
123
- instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
175
+ if common_index_symbols.include?(symbol_str)
176
+ instrument = DhanHQ::Models::Instrument.find("IDX_I", symbol_str)
177
+ if instrument
178
+ return {
179
+ action: "find_instrument",
180
+ params: { symbol: symbol_str },
181
+ result: {
182
+ symbol: symbol_str,
183
+ exchange_segment: "IDX_I",
184
+ security_id: safe_instrument_attr(instrument, :security_id),
185
+ trading_symbol: safe_instrument_attr(instrument, :trading_symbol),
186
+ instrument_type: safe_instrument_attr(instrument, :instrument_type),
187
+ name: safe_instrument_attr(instrument, :name),
188
+ isin: safe_instrument_attr(instrument, :isin)
189
+ }
190
+ }
191
+ end
192
+ end
124
193
 
125
- if instrument
126
- # Use instrument convenience method - automatically uses instrument's attributes
127
- # Returns nested structure: {"data"=>{"NSE_EQ"=>{"2885"=>{...}}}, "status"=>"success"}
128
- quote_response = instrument.quote
194
+ common_segments = %w[NSE_EQ BSE_EQ NSE_FNO BSE_FNO IDX_I NSE_CURRENCY BSE_CURRENCY MCX_COMM]
129
195
 
130
- # Extract actual quote data from nested structure
131
- security_id_str = safe_instrument_attr(instrument, :security_id)&.to_s || security_id.to_s
132
- if quote_response.is_a?(Hash) && quote_response["data"]
133
- quote_data = quote_response.dig("data", exchange_segment,
134
- security_id_str)
135
- end
196
+ common_segments.each do |segment|
197
+ instrument = DhanHQ::Models::Instrument.find(segment, symbol_str)
198
+ next unless instrument
136
199
 
137
- {
138
- action: "get_market_quote",
139
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment },
200
+ return {
201
+ action: "find_instrument",
202
+ params: { symbol: symbol_str },
140
203
  result: {
141
- security_id: safe_instrument_attr(instrument, :security_id) || security_id,
142
- symbol: instrument_symbol,
143
- exchange_segment: exchange_segment,
144
- quote: quote_data || quote_response
204
+ symbol: symbol_str,
205
+ exchange_segment: segment,
206
+ security_id: safe_instrument_attr(instrument, :security_id),
207
+ trading_symbol: safe_instrument_attr(instrument, :trading_symbol),
208
+ instrument_type: safe_instrument_attr(instrument, :instrument_type),
209
+ name: safe_instrument_attr(instrument, :name),
210
+ isin: safe_instrument_attr(instrument, :isin)
145
211
  }
146
212
  }
147
- else
148
- {
149
- action: "get_market_quote",
150
- error: "Instrument not found",
151
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
152
- }
153
213
  end
214
+
215
+ {
216
+ action: "find_instrument",
217
+ error: "Instrument not found in any common exchange segment",
218
+ params: { symbol: symbol_str },
219
+ suggestions: "Try specifying exchange_segment explicitly (NSE_EQ, BSE_EQ, etc.)"
220
+ }
154
221
  rescue StandardError => e
155
222
  {
156
- action: "get_market_quote",
223
+ action: "find_instrument",
157
224
  error: e.message,
158
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
225
+ params: { symbol: symbol }
159
226
  }
160
227
  end
161
228
 
162
- # 2. Live Market Feed API - Get LTP (Last Traded Price) using Instrument convenience method
163
- # Uses instrument.ltp which automatically uses instrument's security_id, exchange_segment, and instrument attributes
229
+ # 1. Market Quote API - Get market quote using Instrument convenience method
230
+ #
231
+ # REQUIRED PARAMETERS:
232
+ # - exchange_segment [String]: Exchange and segment identifier
233
+ # Valid values: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
234
+ # - symbol [String] OR security_id [Integer]: Must provide one
235
+ # symbol: Trading symbol (e.g., "NIFTY", "RELIANCE")
236
+ # security_id: Numeric security ID
237
+ #
238
+ # Returns: Full market quote with OHLC, depth, volume, and other market data
239
+ #
240
+ # Rate limit: 1 request per second
241
+ # Up to 1000 instruments per request
242
+ #
164
243
  # Note: Instrument.find(exchange_segment, symbol) expects symbol (e.g., "NIFTY", "RELIANCE"), not security_id
244
+ def get_market_quote(exchange_segment:, security_id: nil, symbol: nil)
245
+ error = require_symbol_or_security_id(action: "get_market_quote",
246
+ exchange_segment: exchange_segment,
247
+ security_id: security_id,
248
+ symbol: symbol)
249
+ return error if error
250
+
251
+ rate_limit_marketfeed
252
+ exchange_segment = exchange_segment.to_s
253
+
254
+ if security_id && !symbol
255
+ error, security_id_int = validated_security_id_for_marketfeed(
256
+ action: "get_market_quote",
257
+ exchange_segment: exchange_segment,
258
+ security_id: security_id,
259
+ symbol: symbol
260
+ )
261
+ return error if error
262
+
263
+ return market_quote_from_security_id(exchange_segment: exchange_segment,
264
+ security_id_int: security_id_int,
265
+ symbol: symbol)
266
+ end
267
+
268
+ market_quote_from_symbol(exchange_segment: exchange_segment, security_id: security_id, symbol: symbol)
269
+ rescue StandardError => e
270
+ action_error(action: "get_market_quote",
271
+ message: e.message,
272
+ exchange_segment: exchange_segment,
273
+ security_id: security_id,
274
+ symbol: symbol)
275
+ end
276
+
277
+ # 2. Live Market Feed API - Get LTP (Last Traded Price) using Instrument convenience method
278
+ #
279
+ # REQUIRED PARAMETERS:
280
+ # - exchange_segment [String]: Exchange and segment identifier
281
+ # Valid values: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
282
+ # - symbol [String] OR security_id [Integer]: Must provide one
283
+ # symbol: Trading symbol (e.g., "NIFTY", "RELIANCE")
284
+ # security_id: Numeric security ID
285
+ #
286
+ # Returns: Last Traded Price (LTP) - fastest API for current price
287
+ #
165
288
  # Rate limit: 1 request per second
289
+ # Up to 1000 instruments per request
290
+ #
291
+ # Note: Instrument.find(exchange_segment, symbol) expects symbol (e.g., "NIFTY", "RELIANCE"), not security_id
166
292
  def get_live_ltp(exchange_segment:, security_id: nil, symbol: nil)
167
- # Instrument.find expects symbol, support both for backward compatibility
168
- instrument_symbol = symbol || security_id
169
- unless instrument_symbol
170
- return {
171
- action: "get_live_ltp",
172
- error: "Either symbol or security_id must be provided",
173
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
174
- }
175
- end
293
+ error = require_symbol_or_security_id(action: "get_live_ltp",
294
+ exchange_segment: exchange_segment,
295
+ security_id: security_id,
296
+ symbol: symbol)
297
+ return error if error
176
298
 
177
- rate_limit_marketfeed # Enforce rate limiting
178
- instrument_symbol = instrument_symbol.to_s
299
+ rate_limit_marketfeed
179
300
  exchange_segment = exchange_segment.to_s
180
301
 
181
- # Find instrument first
182
- instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
302
+ if security_id && !symbol
303
+ error, security_id_int = validated_security_id_for_marketfeed(
304
+ action: "get_live_ltp",
305
+ exchange_segment: exchange_segment,
306
+ security_id: security_id,
307
+ symbol: symbol
308
+ )
309
+ return error if error
183
310
 
184
- if instrument
185
- # Use instrument convenience method - automatically uses instrument's attributes
186
- # Returns nested structure: {"data"=>{"NSE_EQ"=>{"2885"=>{"last_price"=>1578.1}}}, "status"=>"success"}
187
- # OR direct value: 1578.1 (after retry/rate limit handling)
188
- ltp_response = instrument.ltp
189
-
190
- # Extract LTP from nested structure or use direct value
191
- if ltp_response.is_a?(Hash) && ltp_response["data"]
192
- security_id_str = safe_instrument_attr(instrument, :security_id)&.to_s || security_id.to_s
193
- ltp_data = ltp_response.dig("data", exchange_segment, security_id_str)
194
- ltp = extract_value(ltp_data, [:last_price, "last_price"]) if ltp_data
195
- elsif ltp_response.is_a?(Numeric)
196
- ltp = ltp_response
197
- ltp_data = { last_price: ltp }
198
- else
199
- ltp = extract_value(ltp_response, [:last_price, "last_price", :ltp, "ltp"]) || ltp_response
200
- ltp_data = ltp_response
201
- end
311
+ ltp, ltp_data = ltp_from_marketfeed(exchange_segment: exchange_segment, security_id: security_id_int)
312
+ return live_ltp_response(exchange_segment: exchange_segment,
313
+ security_id: security_id_int,
314
+ symbol: symbol,
315
+ ltp_payload: { ltp: ltp, ltp_data: ltp_data })
316
+ end
202
317
 
203
- {
204
- action: "get_live_ltp",
205
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment },
206
- result: {
207
- security_id: safe_instrument_attr(instrument, :security_id) || security_id,
208
- symbol: instrument_symbol,
209
- exchange_segment: exchange_segment,
210
- ltp: ltp,
211
- ltp_data: ltp_data
212
- }
213
- }
214
- else
215
- {
216
- action: "get_live_ltp",
217
- error: "Instrument not found",
218
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
219
- }
318
+ instrument_symbol = symbol.to_s
319
+ instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
320
+ unless instrument
321
+ return action_error(action: "get_live_ltp",
322
+ message: "Instrument not found",
323
+ exchange_segment: exchange_segment,
324
+ security_id: security_id,
325
+ symbol: symbol)
220
326
  end
327
+
328
+ ltp, ltp_data, resolved_security_id = ltp_from_instrument(exchange_segment: exchange_segment,
329
+ instrument: instrument,
330
+ security_id: security_id)
331
+ live_ltp_response(exchange_segment: exchange_segment,
332
+ security_id: resolved_security_id,
333
+ symbol: symbol,
334
+ ltp_payload: { ltp: ltp, ltp_data: ltp_data },
335
+ instrument_symbol: instrument_symbol)
221
336
  rescue StandardError => e
222
- {
223
- action: "get_live_ltp",
224
- error: e.message,
225
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
226
- }
337
+ action_error(action: "get_live_ltp",
338
+ message: e.message,
339
+ exchange_segment: exchange_segment,
340
+ security_id: security_id,
341
+ symbol: symbol)
227
342
  end
228
343
 
229
344
  # 3. Full Market Depth API - Get full market depth (bid/ask levels)
230
- # Uses instrument.quote which automatically uses instrument's security_id,
231
- # exchange_segment, and instrument attributes
232
- # Note: Instrument.find(exchange_segment, symbol) expects symbol
233
- # (e.g., "NIFTY", "RELIANCE"), not security_id
345
+ #
346
+ # REQUIRED PARAMETERS:
347
+ # - exchange_segment [String]: Exchange and segment identifier
348
+ # Valid values: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
349
+ # - symbol [String] OR security_id [Integer]: Must provide one
350
+ # symbol: Trading symbol (e.g., "NIFTY", "RELIANCE")
351
+ # security_id: Numeric security ID
352
+ #
353
+ # Returns: Full market depth with order book (bid/ask levels), OHLC, volume, OI
354
+ #
234
355
  # Rate limit: 1 request per second (uses quote API which has stricter limits)
356
+ # Up to 1000 instruments per request
357
+ #
358
+ # Note: Instrument.find(exchange_segment, symbol) expects symbol (e.g., "NIFTY", "RELIANCE"), not security_id
235
359
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
236
360
  def get_market_depth(exchange_segment:, security_id: nil, symbol: nil)
237
- # Instrument.find expects symbol, support both for backward compatibility
238
- instrument_symbol = symbol || security_id
239
- unless instrument_symbol
361
+ # CRITICAL: security_id must be an integer, not a symbol string
362
+ unless symbol || security_id
240
363
  return {
241
364
  action: "get_market_depth",
242
365
  error: "Either symbol or security_id must be provided",
@@ -245,10 +368,58 @@ class DhanHQDataTools
245
368
  end
246
369
 
247
370
  rate_limit_marketfeed # Enforce rate limiting
248
- instrument_symbol = instrument_symbol.to_s
249
371
  exchange_segment = exchange_segment.to_s
250
372
 
251
- # Find instrument first
373
+ # If security_id is provided, use it directly - it must be numeric (integer or numeric string), not a symbol
374
+ if security_id && !symbol
375
+ is_valid, result = validate_security_id_numeric(security_id)
376
+ unless is_valid
377
+ return {
378
+ action: "get_market_depth",
379
+ error: result,
380
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
381
+ }
382
+ end
383
+ security_id_int = result
384
+ unless security_id_int.positive?
385
+ return {
386
+ action: "get_market_depth",
387
+ error: "security_id must be a positive integer, got: #{security_id.inspect}",
388
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
389
+ }
390
+ end
391
+
392
+ # Use MarketFeed.quote directly with security_id
393
+ payload = { exchange_segment => [security_id_int] }
394
+ quote_response = DhanHQ::Models::MarketFeed.quote(payload)
395
+
396
+ if quote_response.is_a?(Hash) && quote_response["data"]
397
+ quote_data = quote_response.dig("data", exchange_segment, security_id_int.to_s)
398
+ end
399
+
400
+ depth = extract_value(quote_data, [:depth, "depth"]) if quote_data
401
+ buy_depth = extract_value(depth, [:buy, "buy"]) if depth
402
+ sell_depth = extract_value(depth, [:sell, "sell"]) if depth
403
+
404
+ return {
405
+ action: "get_market_depth",
406
+ params: { security_id: security_id_int, symbol: symbol, exchange_segment: exchange_segment },
407
+ result: {
408
+ security_id: security_id_int,
409
+ exchange_segment: exchange_segment,
410
+ market_depth: quote_data || quote_response,
411
+ buy_depth: buy_depth,
412
+ sell_depth: sell_depth,
413
+ ltp: quote_data ? extract_value(quote_data, [:last_price, "last_price"]) : nil,
414
+ volume: quote_data ? extract_value(quote_data, [:volume, "volume"]) : nil,
415
+ oi: quote_data ? extract_value(quote_data, [:oi, "oi"]) : nil,
416
+ ohlc: quote_data ? extract_value(quote_data, [:ohlc, "ohlc"]) : nil
417
+ }
418
+ }
419
+ end
420
+
421
+ # Find instrument using symbol (Instrument.find expects symbol, not security_id)
422
+ instrument_symbol = symbol.to_s
252
423
  instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
253
424
 
254
425
  if instrument
@@ -303,14 +474,38 @@ class DhanHQDataTools
303
474
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
304
475
 
305
476
  # 4. Historical Data API - Get historical data using HistoricalData class directly
306
- # Requires: security_id, exchange_segment, instrument (type), from_date, to_date
307
- # Optional: interval (for intraday), expiry_code (for futures/options)
477
+ #
478
+ # REQUIRED PARAMETERS:
479
+ # - exchange_segment [String]: Exchange and segment identifier
480
+ # Valid values: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
481
+ # - from_date [String]: Start date in YYYY-MM-DD format (e.g., "2024-01-08")
482
+ # - to_date [String]: End date (non-inclusive) in YYYY-MM-DD format (e.g., "2024-02-08")
483
+ # - symbol [String] OR security_id [Integer]: Must provide one
484
+ # symbol: Trading symbol (e.g., "NIFTY", "RELIANCE")
485
+ # security_id: Numeric security ID
486
+ #
487
+ # OPTIONAL PARAMETERS:
488
+ # - interval [String]: Minute intervals for intraday data
489
+ # Valid values: "1", "5", "15", "25", "60"
490
+ # If provided, returns intraday data; if omitted, returns daily data
491
+ # - expiry_code [Integer]: Expiry code for derivatives
492
+ # Valid values: 0 (far month), 1 (near month), 2 (current month)
493
+ # - instrument [String]: Instrument type
494
+ # Valid values: EQUITY, INDEX, FUTIDX, FUTSTK, OPTIDX, OPTSTK, FUTCOM, OPTFUT, FUTCUR, OPTCUR
495
+ # Auto-detected from exchange_segment if not provided
496
+ #
497
+ # Returns: Historical OHLCV data (arrays of open, high, low, close, volume, timestamp)
498
+ #
499
+ # Notes:
500
+ # - Maximum 90 days of data for intraday requests
501
+ # - Historical data available for the last 5 years
502
+ # - to_date is NON-INCLUSIVE (end date is not included in results)
503
+ #
308
504
  # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
309
505
  def get_historical_data(exchange_segment:, from_date:, to_date:, security_id: nil, symbol: nil, interval: nil,
310
- expiry_code: nil, instrument: nil)
311
- # Need security_id - get it from instrument if we only have symbol
312
- instrument_symbol = symbol || security_id
313
- unless instrument_symbol
506
+ expiry_code: nil, instrument: nil, calculate_indicators: false)
507
+ # CRITICAL: security_id must be an integer, not a symbol string
508
+ unless symbol || security_id
314
509
  return {
315
510
  action: "get_historical_data",
316
511
  error: "Either symbol or security_id must be provided",
@@ -318,27 +513,51 @@ class DhanHQDataTools
318
513
  }
319
514
  end
320
515
 
321
- instrument_symbol = instrument_symbol.to_s
322
516
  exchange_segment = exchange_segment.to_s
323
517
 
324
- # Find instrument to get security_id and instrument type if not provided
325
- found_instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
326
- unless found_instrument
327
- return {
328
- action: "get_historical_data",
329
- error: "Instrument not found",
330
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
331
- }
332
- end
518
+ # If security_id is provided, use it directly - it must be numeric (integer or numeric string), not a symbol
519
+ # If symbol is provided, find instrument first to get security_id and instrument type
520
+ if security_id && !symbol
521
+ is_valid, result = validate_security_id_numeric(security_id)
522
+ unless is_valid
523
+ return {
524
+ action: "get_historical_data",
525
+ error: result,
526
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
527
+ }
528
+ end
529
+ security_id_int = result
530
+ unless security_id_int.positive?
531
+ return {
532
+ action: "get_historical_data",
533
+ error: "security_id must be a positive integer, got: #{security_id.inspect}",
534
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
535
+ }
536
+ end
537
+ resolved_security_id = security_id_int.to_s
538
+ found_instrument = nil
539
+ else
540
+ # Find instrument using symbol (Instrument.find expects symbol, not security_id)
541
+ instrument_symbol = symbol.to_s
542
+ found_instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
543
+ unless found_instrument
544
+ return {
545
+ action: "get_historical_data",
546
+ error: "Instrument not found",
547
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
548
+ }
549
+ end
333
550
 
334
- # Get security_id from instrument if not provided
335
- resolved_security_id = security_id || safe_instrument_attr(found_instrument, :security_id)
336
- unless resolved_security_id
337
- return {
338
- action: "get_historical_data",
339
- error: "security_id is required and could not be determined from instrument",
340
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
341
- }
551
+ # Get security_id from instrument
552
+ resolved_security_id = safe_instrument_attr(found_instrument, :security_id)
553
+ unless resolved_security_id
554
+ return {
555
+ action: "get_historical_data",
556
+ error: "security_id is required and could not be determined from instrument",
557
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
558
+ }
559
+ end
560
+ resolved_security_id = resolved_security_id.to_s
342
561
  end
343
562
 
344
563
  # Determine instrument type if not provided
@@ -350,23 +569,11 @@ class DhanHQDataTools
350
569
  instrument_type_from_obj = instrument_type_from_obj.to_s.upcase if instrument_type_from_obj
351
570
  instrument_type_from_obj = nil unless valid_instruments.include?(instrument_type_from_obj)
352
571
 
353
- # Use provided instrument, or validated instrument from object, or map from exchange_segment
354
- resolved_instrument = if instrument
355
- instrument.to_s.upcase
356
- elsif instrument_type_from_obj
357
- instrument_type_from_obj
572
+ # Determine instrument type if not provided
573
+ resolved_instrument = if found_instrument
574
+ instrument_type_from_obj || default_instrument_for(exchange_segment)
358
575
  else
359
- case exchange_segment
360
- when "IDX_I" then "INDEX"
361
- when "NSE_EQ", "BSE_EQ" then "EQUITY"
362
- # For FNO, default to FUTIDX (can be overridden with expiry_code for options)
363
- when "NSE_FNO", "BSE_FNO" then "FUTIDX"
364
- # For currency, default to FUTCUR
365
- when "NSE_CURRENCY", "BSE_CURRENCY" then "FUTCUR"
366
- # For commodity, default to FUTCOM
367
- when "MCX_COMM" then "FUTCOM"
368
- else "EQUITY"
369
- end
576
+ instrument&.to_s&.upcase || default_instrument_for(exchange_segment)
370
577
  end
371
578
 
372
579
  # Final validation - ensure the resolved instrument type is valid
@@ -377,8 +584,6 @@ class DhanHQDataTools
377
584
  "EQUITY"
378
585
  end
379
586
 
380
- resolved_security_id = resolved_security_id.to_s
381
-
382
587
  if interval
383
588
  # Intraday data using HistoricalData.intraday
384
589
  # Returns hash with :open, :high, :low, :close, :volume, :timestamp arrays
@@ -400,21 +605,57 @@ class DhanHQDataTools
400
605
  0
401
606
  end
402
607
 
403
- {
404
- action: "get_historical_data",
405
- type: "intraday",
406
- params: { security_id: resolved_security_id, symbol: symbol, exchange_segment: exchange_segment,
407
- instrument: resolved_instrument, from_date: from_date, to_date: to_date, interval: interval },
408
- result: {
409
- data: data,
410
- count: count,
411
- instrument_info: {
608
+ # If calculate_indicators is true, calculate technical indicators and return only those
609
+ if calculate_indicators && data.is_a?(Hash) && count.positive?
610
+ indicators = calculate_technical_indicators(data)
611
+ {
612
+ action: "get_historical_data",
613
+ type: "intraday",
614
+ params: {
412
615
  security_id: resolved_security_id,
413
- trading_symbol: safe_instrument_attr(found_instrument, :trading_symbol),
414
- instrument_type: resolved_instrument
616
+ symbol: symbol,
617
+ exchange_segment: exchange_segment,
618
+ instrument: resolved_instrument,
619
+ from_date: from_date,
620
+ to_date: to_date,
621
+ interval: interval
622
+ },
623
+ result: {
624
+ indicators: indicators,
625
+ data_points: count,
626
+ note: "Technical indicators calculated from historical data. " \
627
+ "Raw data not included to reduce response size.",
628
+ instrument_info: {
629
+ security_id: resolved_security_id,
630
+ trading_symbol: safe_instrument_attr(found_instrument, :trading_symbol),
631
+ instrument_type: resolved_instrument
632
+ }
415
633
  }
416
634
  }
417
- }
635
+ else
636
+ {
637
+ action: "get_historical_data",
638
+ type: "intraday",
639
+ params: {
640
+ security_id: resolved_security_id,
641
+ symbol: symbol,
642
+ exchange_segment: exchange_segment,
643
+ instrument: resolved_instrument,
644
+ from_date: from_date,
645
+ to_date: to_date,
646
+ interval: interval
647
+ },
648
+ result: {
649
+ data: data,
650
+ count: count,
651
+ instrument_info: {
652
+ security_id: resolved_security_id,
653
+ trading_symbol: safe_instrument_attr(found_instrument, :trading_symbol),
654
+ instrument_type: resolved_instrument
655
+ }
656
+ }
657
+ }
658
+ end
418
659
  else
419
660
  # Daily data using HistoricalData.daily
420
661
  # Returns hash with :open, :high, :low, :close, :volume, :timestamp arrays
@@ -435,21 +676,57 @@ class DhanHQDataTools
435
676
  0
436
677
  end
437
678
 
438
- {
439
- action: "get_historical_data",
440
- type: "daily",
441
- params: { security_id: resolved_security_id, symbol: symbol, exchange_segment: exchange_segment,
442
- instrument: resolved_instrument, from_date: from_date, to_date: to_date, expiry_code: expiry_code },
443
- result: {
444
- data: data,
445
- count: count,
446
- instrument_info: {
679
+ # If calculate_indicators is true, calculate technical indicators and return only those
680
+ if calculate_indicators && data.is_a?(Hash) && count.positive?
681
+ indicators = calculate_technical_indicators(data)
682
+ {
683
+ action: "get_historical_data",
684
+ type: "daily",
685
+ params: {
447
686
  security_id: resolved_security_id,
448
- trading_symbol: safe_instrument_attr(found_instrument, :trading_symbol),
449
- instrument_type: resolved_instrument
687
+ symbol: symbol,
688
+ exchange_segment: exchange_segment,
689
+ instrument: resolved_instrument,
690
+ from_date: from_date,
691
+ to_date: to_date,
692
+ expiry_code: expiry_code
693
+ },
694
+ result: {
695
+ indicators: indicators,
696
+ data_points: count,
697
+ note: "Technical indicators calculated from historical data. " \
698
+ "Raw data not included to reduce response size.",
699
+ instrument_info: {
700
+ security_id: resolved_security_id,
701
+ trading_symbol: safe_instrument_attr(found_instrument, :trading_symbol),
702
+ instrument_type: resolved_instrument
703
+ }
450
704
  }
451
705
  }
452
- }
706
+ else
707
+ {
708
+ action: "get_historical_data",
709
+ type: "daily",
710
+ params: {
711
+ security_id: resolved_security_id,
712
+ symbol: symbol,
713
+ exchange_segment: exchange_segment,
714
+ instrument: resolved_instrument,
715
+ from_date: from_date,
716
+ to_date: to_date,
717
+ expiry_code: expiry_code
718
+ },
719
+ result: {
720
+ data: data,
721
+ count: count,
722
+ instrument_info: {
723
+ security_id: resolved_security_id,
724
+ trading_symbol: safe_instrument_attr(found_instrument, :trading_symbol),
725
+ instrument_type: resolved_instrument
726
+ }
727
+ }
728
+ }
729
+ end
453
730
  end
454
731
  rescue StandardError => e
455
732
  {
@@ -458,115 +735,523 @@ class DhanHQDataTools
458
735
  params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
459
736
  }
460
737
  end
738
+
739
+ def default_instrument_for(exchange_segment)
740
+ defaults = {
741
+ "IDX_I" => "INDEX",
742
+ "NSE_FNO" => "FUTIDX",
743
+ "BSE_FNO" => "FUTIDX",
744
+ "NSE_CURRENCY" => "FUTCUR",
745
+ "BSE_CURRENCY" => "FUTCUR",
746
+ "MCX_COMM" => "FUTCOM"
747
+ }
748
+
749
+ defaults.fetch(exchange_segment, "EQUITY")
750
+ end
751
+
752
+ # Calculate technical indicators from historical data
753
+ # Returns only indicator values, not raw data
754
+ def calculate_technical_indicators(data)
755
+ return {} unless data.is_a?(Hash)
756
+
757
+ # Extract OHLCV arrays (handle both symbol and string keys)
758
+ highs = data[:high] || data["high"] || []
759
+ lows = data[:low] || data["low"] || []
760
+ closes = data[:close] || data["close"] || []
761
+ volumes = data[:volume] || data["volume"] || []
762
+ timestamps = data[:timestamp] || data["timestamp"] || []
763
+
764
+ return {} if closes.empty?
765
+
766
+ # Convert to arrays of floats
767
+ closes_f = closes.map(&:to_f)
768
+ highs_f = highs.map(&:to_f)
769
+ lows_f = lows.map(&:to_f)
770
+ volumes_f = volumes.map(&:to_f)
771
+
772
+ # Calculate indicators
773
+ sma20 = DhanHQ::Indicators::TechnicalIndicators.sma(closes_f, 20)
774
+ sma50 = DhanHQ::Indicators::TechnicalIndicators.sma(closes_f, 50)
775
+ ema12 = DhanHQ::Indicators::TechnicalIndicators.ema(closes_f, 12)
776
+ ema26 = DhanHQ::Indicators::TechnicalIndicators.ema(closes_f, 26)
777
+ rsi = DhanHQ::Indicators::TechnicalIndicators.rsi(closes_f, 14)
778
+ macd = DhanHQ::Indicators::TechnicalIndicators.macd(closes_f)
779
+ bollinger = DhanHQ::Indicators::TechnicalIndicators.bollinger_bands(closes_f, 20, 2)
780
+ atr = DhanHQ::Indicators::TechnicalIndicators.atr(highs_f, lows_f, closes_f, 14)
781
+
782
+ # Get latest values
783
+ {
784
+ current_price: closes_f.last,
785
+ sma20: sma20.last,
786
+ sma50: sma50.last,
787
+ ema12: ema12.last,
788
+ ema26: ema26.last,
789
+ rsi: rsi.last,
790
+ macd: {
791
+ macd: macd[:macd].last,
792
+ signal: macd[:signal].last,
793
+ histogram: macd[:histogram].last
794
+ },
795
+ bollinger_bands: {
796
+ upper: bollinger[:upper].last,
797
+ middle: bollinger[:middle].last,
798
+ lower: bollinger[:lower].last
799
+ },
800
+ atr: atr.last,
801
+ price_range: {
802
+ high: highs_f.max,
803
+ low: lows_f.min,
804
+ current: closes_f.last
805
+ },
806
+ volume: {
807
+ current: volumes_f.last,
808
+ average: volumes_f.any? ? (volumes_f.sum.to_f / volumes_f.length) : nil
809
+ },
810
+ data_points: closes_f.length,
811
+ last_timestamp: timestamps.last
812
+ }
813
+ rescue StandardError => e
814
+ {
815
+ error: "Failed to calculate indicators: #{e.message}"
816
+ }
817
+ end
461
818
  # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
462
819
 
463
- # 6. Option Chain API - Get option chain using OptionChain.fetch
464
- # Requires: underlying_scrip (security_id), underlying_seg (exchange_segment), expiry (optional)
820
+ # 6a. Option Expiry List API - Get list of available expiry dates for an underlying instrument
821
+ #
822
+ # REQUIRED PARAMETERS:
823
+ # - exchange_segment [String]: Exchange and segment of underlying
824
+ # For indices (NIFTY, BANKNIFTY): Use "IDX_I"
825
+ # For stocks: Use "NSE_FNO" or "BSE_FNO"
826
+ # Valid values: IDX_I (Index), NSE_FNO (NSE F&O), BSE_FNO (BSE F&O), MCX_FO (MCX)
827
+ # - symbol [String] OR security_id [Integer]: Must provide one
828
+ # symbol: Trading symbol (e.g., "NIFTY", "BANKNIFTY")
829
+ # security_id: Numeric security ID of underlying (use value from find_instrument)
830
+ #
831
+ # Returns: Array of available expiry dates in "YYYY-MM-DD" format
832
+ #
833
+ # Rate limit: 1 request per 3 seconds
834
+ #
465
835
  # Note: For index options, underlying_seg should be "IDX_I", not "NSE_FNO"
466
- def get_option_chain(exchange_segment:, security_id: nil, symbol: nil, expiry: nil)
467
- # Need security_id - get it from instrument if we only have symbol
468
- instrument_symbol = symbol || security_id
469
- unless instrument_symbol
836
+ def get_expiry_list(exchange_segment:, security_id: nil, symbol: nil)
837
+ # CRITICAL: security_id must be an integer, not a symbol string
838
+ unless symbol || security_id
470
839
  return {
471
- action: "get_option_chain",
840
+ action: "get_expiry_list",
472
841
  error: "Either symbol or security_id must be provided",
473
842
  params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
474
843
  }
475
844
  end
476
845
 
477
- instrument_symbol = instrument_symbol.to_s
478
846
  exchange_segment = exchange_segment.to_s
479
847
 
480
- # For option chains, we need the underlying security_id
481
- # If exchange_segment is NSE_FNO/BSE_FNO, try to find the underlying in IDX_I or NSE_EQ
482
- underlying_seg = exchange_segment
483
- found_instrument = nil
848
+ # If security_id is provided, use it directly - it must be numeric (integer or numeric string), not a symbol
849
+ if security_id
850
+ is_valid, result = validate_security_id_numeric(security_id)
851
+ unless is_valid
852
+ return {
853
+ action: "get_expiry_list",
854
+ error: result,
855
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
856
+ }
857
+ end
858
+ security_id_int = result
859
+ unless security_id_int.positive?
860
+ return {
861
+ action: "get_expiry_list",
862
+ error: "security_id must be a positive integer, got: #{security_id.inspect}",
863
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
864
+ }
865
+ end
484
866
 
485
- # Try to find instrument to get security_id
486
- found_instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
487
- # If not found and exchange_segment is NSE_FNO/BSE_FNO, try IDX_I for index options
488
- if !found_instrument && %w[NSE_FNO BSE_FNO].include?(exchange_segment)
489
- found_instrument = DhanHQ::Models::Instrument.find("IDX_I", instrument_symbol)
490
- underlying_seg = "IDX_I" if found_instrument
867
+ underlying_seg = if exchange_segment == "IDX_I"
868
+ "IDX_I"
869
+ else
870
+ exchange_segment
871
+ end
872
+ resolved_security_id = security_id_int
873
+ found_instrument = nil
874
+ elsif symbol
875
+ instrument_symbol = symbol.to_s
876
+ underlying_seg, found_instrument = find_underlying_instrument(exchange_segment, instrument_symbol)
877
+ resolved_security_id = resolve_security_id_for_option_chain(nil, found_instrument)
491
878
  end
492
879
 
493
- # Get security_id from instrument if not provided
494
- resolved_security_id = if security_id
495
- security_id.to_i
496
- elsif found_instrument
497
- safe_instrument_attr(found_instrument, :security_id)&.to_i
498
- else
499
- nil
500
- end
501
-
502
880
  unless resolved_security_id
503
881
  return {
504
- action: "get_option_chain",
882
+ action: "get_expiry_list",
505
883
  error: "security_id is required and could not be determined from instrument",
506
884
  params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
507
885
  }
508
886
  end
509
887
 
510
- if expiry
511
- # Get option chain for specific expiry using OptionChain.fetch
512
- chain = DhanHQ::Models::OptionChain.fetch(
513
- underlying_scrip: resolved_security_id,
514
- underlying_seg: underlying_seg,
515
- expiry: expiry.to_s
516
- )
517
- {
518
- action: "get_option_chain",
519
- params: { security_id: resolved_security_id, symbol: symbol, exchange_segment: exchange_segment,
520
- expiry: expiry },
521
- result: {
522
- expiry: expiry,
523
- underlying_last_price: chain[:last_price],
524
- chain: chain[:oc], # oc contains strike prices as keys
525
- instrument_info: {
526
- underlying_security_id: resolved_security_id,
527
- underlying_seg: underlying_seg,
528
- trading_symbol: found_instrument ? safe_instrument_attr(found_instrument, :trading_symbol) : symbol
529
- }
888
+ expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(
889
+ underlying_scrip: resolved_security_id,
890
+ underlying_seg: underlying_seg
891
+ )
892
+
893
+ {
894
+ action: "get_expiry_list",
895
+ params: {
896
+ security_id: resolved_security_id,
897
+ symbol: symbol,
898
+ exchange_segment: exchange_segment
899
+ }.compact,
900
+ result: {
901
+ expiries: expiries,
902
+ count: expiries.is_a?(Array) ? expiries.length : 0,
903
+ instrument_info: {
904
+ underlying_security_id: resolved_security_id,
905
+ underlying_seg: underlying_seg,
906
+ trading_symbol: found_instrument ? safe_instrument_attr(found_instrument, :trading_symbol) : symbol
530
907
  }
531
908
  }
909
+ }
910
+ rescue StandardError => e
911
+ {
912
+ action: "get_expiry_list",
913
+ error: e.message,
914
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
915
+ }
916
+ end
917
+
918
+ # 6. Option Chain API - Get option chain using OptionChain.fetch
919
+ #
920
+ # REQUIRED PARAMETERS:
921
+ # - exchange_segment [String]: Exchange and segment of underlying
922
+ # For indices (NIFTY, BANKNIFTY): Use "IDX_I"
923
+ # For stocks: Use "NSE_FNO" or "BSE_FNO"
924
+ # Valid values: IDX_I (Index), NSE_FNO (NSE F&O), BSE_FNO (BSE F&O), MCX_FO (MCX)
925
+ # - symbol [String] OR security_id [Integer]: Must provide one
926
+ # symbol: Trading symbol (e.g., "NIFTY", "BANKNIFTY")
927
+ # security_id: Numeric security ID of underlying (use value from find_instrument)
928
+ # - expiry [String]: Expiry date in YYYY-MM-DD format (REQUIRED)
929
+ #
930
+ # Returns: Option chain with CE/PE data for all strikes
931
+ #
932
+ # Rate limit: 1 request per 3 seconds
933
+ # Automatically filters out strikes where both CE and PE have zero last_price
934
+ #
935
+ # Note: For index options, underlying_seg should be "IDX_I", not "NSE_FNO"
936
+ # Note: Use get_expiry_list first to get available expiry dates if you don't know the expiry
937
+ # Note: Chain is automatically filtered to show strikes around ATM (default: 5 strikes = 2 ITM, ATM, 2 OTM)
938
+ def get_option_chain(exchange_segment:, security_id: nil, symbol: nil, expiry: nil, strikes_count: 5)
939
+ # CRITICAL: security_id must be an integer, not a symbol string
940
+ unless symbol || security_id
941
+ return option_chain_error(
942
+ "Either symbol or security_id must be provided",
943
+ security_id: security_id,
944
+ symbol: symbol,
945
+ exchange_segment: exchange_segment
946
+ )
947
+ end
948
+
949
+ exchange_segment = exchange_segment.to_s
950
+
951
+ # If security_id is provided, use it directly - it must be numeric (integer or numeric string), not a symbol
952
+ # CRITICAL: Prefer security_id over symbol when both are provided, since security_id is more reliable
953
+ if security_id
954
+ is_valid, result = validate_security_id_numeric(security_id)
955
+ unless is_valid
956
+ return option_chain_error(
957
+ result,
958
+ security_id: security_id,
959
+ symbol: symbol,
960
+ exchange_segment: exchange_segment
961
+ )
962
+ end
963
+ security_id_int = result
964
+ unless security_id_int.positive?
965
+ return option_chain_error(
966
+ "security_id must be a positive integer, got: #{security_id.inspect}",
967
+ security_id: security_id,
968
+ symbol: symbol,
969
+ exchange_segment: exchange_segment
970
+ )
971
+ end
972
+
973
+ # For option chain, underlying_seg should be IDX_I for indices, or match exchange_segment
974
+ # For indices like NIFTY (IDX_I), underlying_seg must be IDX_I
975
+ underlying_seg = if exchange_segment == "IDX_I"
976
+ "IDX_I"
977
+ else
978
+ exchange_segment
979
+ end
980
+ resolved_security_id = security_id_int
981
+ found_instrument = nil
982
+ elsif symbol
983
+ # Find instrument using symbol (Instrument.find expects symbol, not security_id)
984
+ instrument_symbol = symbol.to_s
985
+ underlying_seg, found_instrument = find_underlying_instrument(exchange_segment, instrument_symbol)
986
+
987
+ # Get security_id from instrument
988
+ resolved_security_id = resolve_security_id_for_option_chain(nil, found_instrument)
532
989
  else
533
- # Get list of available expiries using OptionChain.fetch_expiry_list
534
- expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(
535
- underlying_scrip: resolved_security_id,
536
- underlying_seg: underlying_seg
990
+ return option_chain_error(
991
+ "Either symbol or security_id must be provided",
992
+ security_id: security_id,
993
+ symbol: symbol,
994
+ exchange_segment: exchange_segment
537
995
  )
996
+ end
997
+
998
+ unless resolved_security_id
999
+ return option_chain_error(
1000
+ "security_id is required and could not be determined from instrument",
1001
+ security_id: security_id,
1002
+ symbol: symbol,
1003
+ exchange_segment: exchange_segment
1004
+ )
1005
+ end
1006
+
1007
+ # Validate expiry is provided and not empty
1008
+ expiry_str = expiry.to_s.strip if expiry
1009
+ if expiry_str.nil? || expiry_str.empty?
1010
+ return option_chain_error(
1011
+ "expiry is required. Use get_expiry_list to get available expiry dates first.",
1012
+ security_id: security_id,
1013
+ symbol: symbol,
1014
+ exchange_segment: exchange_segment,
1015
+ expiry: expiry
1016
+ )
1017
+ end
1018
+
1019
+ option_chain_for_expiry(
538
1020
  {
539
- action: "get_option_chain",
540
- params: { security_id: resolved_security_id, symbol: symbol, exchange_segment: exchange_segment },
541
- result: {
542
- expiries: expiries,
543
- count: expiries.is_a?(Array) ? expiries.length : 0,
544
- instrument_info: {
545
- underlying_security_id: resolved_security_id,
546
- underlying_seg: underlying_seg,
547
- trading_symbol: found_instrument ? safe_instrument_attr(found_instrument, :trading_symbol) : symbol
548
- }
549
- }
1021
+ resolved_security_id: resolved_security_id,
1022
+ underlying_seg: underlying_seg,
1023
+ exchange_segment: exchange_segment,
1024
+ symbol: symbol,
1025
+ expiry: expiry,
1026
+ found_instrument: found_instrument,
1027
+ strikes_count: strikes_count
550
1028
  }
551
- end
1029
+ )
552
1030
  rescue StandardError => e
553
1031
  {
554
1032
  action: "get_option_chain",
555
1033
  error: e.message,
556
- params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
1034
+ params: {
1035
+ security_id: security_id,
1036
+ symbol: symbol,
1037
+ exchange_segment: exchange_segment,
1038
+ expiry: expiry
1039
+ }.compact
1040
+ }
1041
+ end
1042
+
1043
+ def find_underlying_instrument(exchange_segment, instrument_symbol)
1044
+ found_instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
1045
+ return [exchange_segment, found_instrument] if found_instrument
1046
+ return [exchange_segment, nil] unless %w[NSE_FNO BSE_FNO].include?(exchange_segment)
1047
+
1048
+ fallback_instrument = DhanHQ::Models::Instrument.find("IDX_I", instrument_symbol)
1049
+ return ["IDX_I", fallback_instrument] if fallback_instrument
1050
+
1051
+ [exchange_segment, nil]
1052
+ end
1053
+
1054
+ def resolve_security_id_for_option_chain(security_id, found_instrument)
1055
+ return security_id.to_i if security_id
1056
+
1057
+ safe_instrument_attr(found_instrument, :security_id)&.to_i
1058
+ end
1059
+
1060
+ def option_chain_for_expiry(params)
1061
+ resolved_security_id = params.fetch(:resolved_security_id)
1062
+ underlying_seg = params.fetch(:underlying_seg)
1063
+ exchange_segment = params.fetch(:exchange_segment)
1064
+ symbol = params.fetch(:symbol)
1065
+ expiry = params.fetch(:expiry)
1066
+ found_instrument = params[:found_instrument]
1067
+ strikes_count = params.fetch(:strikes_count, 5) # Default: 5 strikes (2 ITM, ATM, 2 OTM)
1068
+
1069
+ # Ensure expiry is a string in YYYY-MM-DD format
1070
+ expiry_str = expiry.to_s.strip
1071
+
1072
+ # Validate expiry format (basic check)
1073
+ unless expiry_str.match?(/^\d{4}-\d{2}-\d{2}$/)
1074
+ return option_chain_error(
1075
+ "expiry must be in YYYY-MM-DD format, got: #{expiry.inspect}",
1076
+ security_id: resolved_security_id,
1077
+ symbol: symbol,
1078
+ exchange_segment: exchange_segment,
1079
+ expiry: expiry
1080
+ )
1081
+ end
1082
+
1083
+ # Ensure underlying_scrip is an integer (as per API docs)
1084
+ underlying_scrip_int = resolved_security_id.to_i
1085
+ unless underlying_scrip_int.positive?
1086
+ return option_chain_error(
1087
+ "underlying_scrip (security_id) must be a positive integer, got: #{resolved_security_id.inspect}",
1088
+ security_id: resolved_security_id,
1089
+ symbol: symbol,
1090
+ exchange_segment: exchange_segment,
1091
+ expiry: expiry
1092
+ )
1093
+ end
1094
+
1095
+ # Validate underlying_seg (as per API docs: IDX_I, NSE_FNO, BSE_FNO, MCX_FO)
1096
+ valid_underlying_segs = %w[IDX_I NSE_FNO BSE_FNO MCX_FO]
1097
+ unless valid_underlying_segs.include?(underlying_seg)
1098
+ return option_chain_error(
1099
+ "underlying_seg must be one of: #{valid_underlying_segs.join(', ')}, got: #{underlying_seg.inspect}",
1100
+ security_id: resolved_security_id,
1101
+ symbol: symbol,
1102
+ exchange_segment: exchange_segment,
1103
+ expiry: expiry
1104
+ )
1105
+ end
1106
+
1107
+ chain = DhanHQ::Models::OptionChain.fetch(
1108
+ underlying_scrip: underlying_scrip_int,
1109
+ underlying_seg: underlying_seg,
1110
+ expiry: expiry_str
1111
+ )
1112
+
1113
+ # Extract last_price and filter chain to only relevant strikes around ATM
1114
+ last_price = chain[:last_price] || chain["last_price"]
1115
+ full_chain = chain[:oc] || chain["oc"] || {}
1116
+
1117
+ # Filter chain to include strikes around ATM (default: 5 strikes = 2 ITM, ATM, 2 OTM)
1118
+ filtered_chain = filter_option_chain(full_chain, last_price, strikes_count)
1119
+
1120
+ {
1121
+ action: "get_option_chain",
1122
+ params: option_chain_params(
1123
+ resolved_security_id: resolved_security_id,
1124
+ exchange_segment: exchange_segment,
1125
+ symbol: symbol,
1126
+ expiry: expiry
1127
+ ),
1128
+ result: {
1129
+ expiry: expiry,
1130
+ underlying_last_price: last_price,
1131
+ chain: filtered_chain,
1132
+ chain_count: filtered_chain.is_a?(Hash) ? filtered_chain.keys.length : 0,
1133
+ note: "Chain filtered to show #{strikes_count} strikes around ATM (ITM, ATM, OTM)",
1134
+ instrument_info: option_chain_instrument_info(
1135
+ resolved_security_id: resolved_security_id,
1136
+ underlying_seg: underlying_seg,
1137
+ found_instrument: found_instrument,
1138
+ symbol: symbol
1139
+ )
1140
+ }
1141
+ }
1142
+ end
1143
+
1144
+ def option_chain_expiry_list(resolved_security_id:, underlying_seg:, exchange_segment:, symbol:, found_instrument:)
1145
+ expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(
1146
+ underlying_scrip: resolved_security_id,
1147
+ underlying_seg: underlying_seg
1148
+ )
1149
+
1150
+ {
1151
+ action: "get_option_chain",
1152
+ params: option_chain_params(
1153
+ resolved_security_id: resolved_security_id,
1154
+ exchange_segment: exchange_segment,
1155
+ symbol: symbol
1156
+ ),
1157
+ result: {
1158
+ expiries: expiries,
1159
+ count: expiries.is_a?(Array) ? expiries.length : 0,
1160
+ instrument_info: option_chain_instrument_info(
1161
+ resolved_security_id: resolved_security_id,
1162
+ underlying_seg: underlying_seg,
1163
+ found_instrument: found_instrument,
1164
+ symbol: symbol
1165
+ )
1166
+ }
1167
+ }
1168
+ end
1169
+
1170
+ def filter_option_chain(full_chain, last_price, strikes_count = 5)
1171
+ return {} unless valid_option_chain_inputs?(full_chain, last_price)
1172
+
1173
+ strikes = extract_chain_strikes(full_chain)
1174
+ return {} if strikes.empty?
1175
+
1176
+ selected_strikes = select_strike_keys(strikes, last_price.to_f, strikes_count)
1177
+ return {} if selected_strikes.empty?
1178
+
1179
+ build_filtered_chain(full_chain, selected_strikes)
1180
+ end
1181
+
1182
+ def option_chain_instrument_info(resolved_security_id:, underlying_seg:, found_instrument:, symbol:)
1183
+ {
1184
+ underlying_security_id: resolved_security_id,
1185
+ underlying_seg: underlying_seg,
1186
+ trading_symbol: found_instrument ? safe_instrument_attr(found_instrument, :trading_symbol) : symbol
1187
+ }
1188
+ end
1189
+
1190
+ def option_chain_params(resolved_security_id:, exchange_segment:, symbol:, expiry: nil)
1191
+ {
1192
+ security_id: resolved_security_id,
1193
+ symbol: symbol,
1194
+ exchange_segment: exchange_segment,
1195
+ expiry: expiry
1196
+ }.compact
1197
+ end
1198
+
1199
+ def option_chain_error(message, security_id:, symbol:, exchange_segment:, expiry: nil)
1200
+ {
1201
+ action: "get_option_chain",
1202
+ error: message,
1203
+ params: {
1204
+ security_id: security_id,
1205
+ symbol: symbol,
1206
+ exchange_segment: exchange_segment,
1207
+ expiry: expiry
1208
+ }.compact
557
1209
  }
558
1210
  end
559
1211
 
560
1212
  # 5. Expired Options Data API - Get historical expired options data using ExpiredOptionsData.fetch
561
- # Requires: exchange_segment, interval, security_id, instrument (OPTIDX/OPTSTK), expiry_flag, expiry_code,
562
- # strike, drv_option_type, required_data, from_date, to_date
1213
+ #
1214
+ # REQUIRED PARAMETERS:
1215
+ # - exchange_segment [String]: Exchange and segment identifier
1216
+ # Valid values: NSE_FNO, BSE_FNO, NSE_EQ, BSE_EQ
1217
+ # - expiry_date [String]: Expiry date in YYYY-MM-DD format
1218
+ # - symbol [String] OR security_id [Integer]: Must provide one
1219
+ # symbol: Trading symbol of underlying
1220
+ # security_id: Numeric security ID of underlying
1221
+ #
1222
+ # OPTIONAL PARAMETERS (with defaults):
1223
+ # - interval [String]: Minute intervals for timeframe (default: "1")
1224
+ # Valid values: "1", "5", "15", "25", "60"
1225
+ # - instrument [String]: Instrument type (default: auto-detected)
1226
+ # Valid values: "OPTIDX" (Index Options), "OPTSTK" (Stock Options)
1227
+ # Default: "OPTIDX" for IDX_I, "OPTSTK" for others
1228
+ # - expiry_flag [String]: Expiry interval (default: "MONTH")
1229
+ # Valid values: "WEEK", "MONTH"
1230
+ # - expiry_code [Integer]: Expiry code (default: 1 - near month)
1231
+ # Valid values: 0 (far month), 1 (near month), 2 (current month)
1232
+ # - strike [String]: Strike price specification (default: "ATM")
1233
+ # Format: "ATM" for At The Money, "ATM+X" or "ATM-X" for offset strikes
1234
+ # For Index Options (near expiry): Up to ATM+10 / ATM-10
1235
+ # For all other contracts: Up to ATM+3 / ATM-3
1236
+ # - drv_option_type [String]: Option type (default: "CALL")
1237
+ # Valid values: "CALL", "PUT"
1238
+ # - required_data [Array<String>]: Array of required data fields (default: all fields)
1239
+ # Valid values: "open", "high", "low", "close", "iv", "volume", "strike", "oi", "spot"
1240
+ #
1241
+ # Returns: Historical expired options data organized by strike price relative to spot
1242
+ #
1243
+ # Notes:
1244
+ # - Up to 31 days of data can be fetched in a single request
1245
+ # - Historical data available for up to the last 5 years
1246
+ # - Data is organized by strike price relative to spot
1247
+ # - from_date is calculated from expiry_date, to_date is expiry_date + 1 day
1248
+ #
563
1249
  # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
564
1250
  def get_expired_options_data(exchange_segment:, expiry_date:, security_id: nil, symbol: nil, expiry_code: nil,
565
1251
  interval: "1", instrument: nil, expiry_flag: "MONTH", strike: "ATM",
566
1252
  drv_option_type: "CALL", required_data: nil)
567
- # Need security_id - get it from instrument if we only have symbol
568
- instrument_symbol = symbol || security_id
569
- unless instrument_symbol
1253
+ # CRITICAL: security_id must be an integer, not a symbol string
1254
+ unless symbol || security_id
570
1255
  return {
571
1256
  action: "get_expired_options_data",
572
1257
  error: "Either symbol or security_id must be provided",
@@ -575,15 +1260,34 @@ class DhanHQDataTools
575
1260
  }
576
1261
  end
577
1262
 
578
- instrument_symbol = instrument_symbol.to_s
579
1263
  exchange_segment = exchange_segment.to_s
580
1264
 
581
- # If security_id is provided directly, use it; otherwise find instrument
582
- if security_id
583
- resolved_security_id = security_id.to_s
584
- found_instrument = nil # Don't need to find instrument if we have security_id
1265
+ # If security_id is provided, use it directly - it must be numeric (integer or numeric string), not a symbol
1266
+ if security_id && !symbol
1267
+ is_valid, result = validate_security_id_numeric(security_id)
1268
+ unless is_valid
1269
+ return {
1270
+ action: "get_expired_options_data",
1271
+ error: result,
1272
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment,
1273
+ expiry_date: expiry_date }
1274
+ }
1275
+ end
1276
+ security_id_int = result
1277
+ unless security_id_int.positive?
1278
+ return {
1279
+ action: "get_expired_options_data",
1280
+ error: "security_id must be a positive integer, got: #{security_id.inspect}",
1281
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment,
1282
+ expiry_date: expiry_date }
1283
+ }
1284
+ end
1285
+ resolved_security_id = security_id_int
1286
+ found_instrument = nil
585
1287
  else
586
- # Find instrument to get security_id - try original exchange_segment first, then try IDX_I for indices
1288
+ # Find instrument using symbol (Instrument.find expects symbol, not security_id)
1289
+ instrument_symbol = symbol.to_s
1290
+ # Try original exchange_segment first, then try IDX_I for indices
587
1291
  found_instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
588
1292
  # If not found and exchange_segment is NSE_FNO/BSE_FNO, try IDX_I for index options
589
1293
  if !found_instrument && %w[NSE_FNO BSE_FNO].include?(exchange_segment)
@@ -609,7 +1313,7 @@ class DhanHQDataTools
609
1313
  expiry_date: expiry_date }
610
1314
  }
611
1315
  end
612
- resolved_security_id = resolved_security_id.to_s
1316
+ resolved_security_id = resolved_security_id.to_i
613
1317
  end
614
1318
 
615
1319
  # Determine instrument type - must be OPTIDX or OPTSTK
@@ -652,7 +1356,7 @@ class DhanHQDataTools
652
1356
  expired_data = DhanHQ::Models::ExpiredOptionsData.fetch(
653
1357
  exchange_segment: exchange_segment,
654
1358
  interval: interval.to_s,
655
- security_id: resolved_security_id.to_i,
1359
+ security_id: resolved_security_id,
656
1360
  instrument: resolved_instrument,
657
1361
  expiry_flag: expiry_flag.to_s.upcase,
658
1362
  expiry_code: resolved_expiry_code.to_i,
@@ -694,6 +1398,209 @@ class DhanHQDataTools
694
1398
  }
695
1399
  end
696
1400
  # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1401
+
1402
+ private
1403
+
1404
+ def action_error(action:, message:, exchange_segment:, security_id:, symbol:)
1405
+ {
1406
+ action: action,
1407
+ error: message,
1408
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment }
1409
+ }
1410
+ end
1411
+
1412
+ def require_symbol_or_security_id(action:, exchange_segment:, security_id:, symbol:)
1413
+ return nil if symbol || security_id
1414
+
1415
+ action_error(action: action,
1416
+ message: "Either symbol or security_id must be provided",
1417
+ exchange_segment: exchange_segment,
1418
+ security_id: security_id,
1419
+ symbol: symbol)
1420
+ end
1421
+
1422
+ def validated_security_id_for_marketfeed(action:, exchange_segment:, security_id:, symbol:)
1423
+ is_valid, result = validate_security_id_numeric(security_id)
1424
+ unless is_valid
1425
+ return [action_error(action: action,
1426
+ message: result,
1427
+ exchange_segment: exchange_segment,
1428
+ security_id: security_id,
1429
+ symbol: symbol), nil]
1430
+ end
1431
+
1432
+ return [nil, result] if result.positive?
1433
+
1434
+ [action_error(action: action,
1435
+ message: "security_id must be a positive integer, got: #{security_id.inspect}",
1436
+ exchange_segment: exchange_segment,
1437
+ security_id: security_id,
1438
+ symbol: symbol), nil]
1439
+ end
1440
+
1441
+ def extract_quote_data(quote_response, exchange_segment:, security_id:)
1442
+ return unless quote_response.is_a?(Hash) && quote_response["data"]
1443
+
1444
+ quote_response.dig("data", exchange_segment, security_id.to_s)
1445
+ end
1446
+
1447
+ def market_quote_response(exchange_segment:, security_id:, symbol:, quote:, instrument_symbol: nil)
1448
+ result = {
1449
+ security_id: security_id,
1450
+ exchange_segment: exchange_segment,
1451
+ quote: quote
1452
+ }
1453
+ result[:symbol] = instrument_symbol if instrument_symbol
1454
+
1455
+ {
1456
+ action: "get_market_quote",
1457
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment },
1458
+ result: result
1459
+ }
1460
+ end
1461
+
1462
+ def market_quote_from_security_id(exchange_segment:, security_id_int:, symbol:)
1463
+ payload = { exchange_segment => [security_id_int] }
1464
+ quote_response = DhanHQ::Models::MarketFeed.quote(payload)
1465
+ quote_data = extract_quote_data(quote_response, exchange_segment: exchange_segment, security_id: security_id_int)
1466
+
1467
+ market_quote_response(exchange_segment: exchange_segment,
1468
+ security_id: security_id_int,
1469
+ symbol: symbol,
1470
+ quote: quote_data || quote_response)
1471
+ end
1472
+
1473
+ def market_quote_from_symbol(exchange_segment:, security_id:, symbol:)
1474
+ instrument_symbol = symbol.to_s
1475
+ instrument = DhanHQ::Models::Instrument.find(exchange_segment, instrument_symbol)
1476
+ unless instrument
1477
+ return action_error(action: "get_market_quote",
1478
+ message: "Instrument not found",
1479
+ exchange_segment: exchange_segment,
1480
+ security_id: security_id,
1481
+ symbol: symbol)
1482
+ end
1483
+
1484
+ quote_response = instrument.quote
1485
+ resolved_security_id = safe_instrument_attr(instrument, :security_id) || security_id
1486
+ quote_data = extract_quote_data(quote_response,
1487
+ exchange_segment: exchange_segment,
1488
+ security_id: resolved_security_id || security_id)
1489
+
1490
+ market_quote_response(exchange_segment: exchange_segment,
1491
+ security_id: resolved_security_id,
1492
+ symbol: symbol,
1493
+ quote: quote_data || quote_response,
1494
+ instrument_symbol: instrument_symbol)
1495
+ end
1496
+
1497
+ def live_ltp_response(exchange_segment:, security_id:, symbol:, ltp_payload:, instrument_symbol: nil)
1498
+ result = {
1499
+ security_id: security_id,
1500
+ exchange_segment: exchange_segment,
1501
+ ltp: ltp_payload[:ltp],
1502
+ ltp_data: ltp_payload[:ltp_data]
1503
+ }
1504
+ result[:symbol] = instrument_symbol if instrument_symbol
1505
+
1506
+ {
1507
+ action: "get_live_ltp",
1508
+ params: { security_id: security_id, symbol: symbol, exchange_segment: exchange_segment },
1509
+ result: result
1510
+ }
1511
+ end
1512
+
1513
+ def ltp_from_marketfeed(exchange_segment:, security_id:)
1514
+ payload = { exchange_segment => [security_id] }
1515
+ ltp_response = DhanHQ::Models::MarketFeed.ltp(payload)
1516
+ parse_ltp_response(ltp_response, exchange_segment: exchange_segment, security_id: security_id)
1517
+ end
1518
+
1519
+ def ltp_from_instrument(exchange_segment:, instrument:, security_id:)
1520
+ ltp_response = instrument.ltp
1521
+ resolved_security_id = safe_instrument_attr(instrument, :security_id) || security_id
1522
+ ltp, ltp_data = parse_ltp_response(ltp_response,
1523
+ exchange_segment: exchange_segment,
1524
+ security_id: resolved_security_id || security_id)
1525
+
1526
+ [ltp, ltp_data, resolved_security_id]
1527
+ end
1528
+
1529
+ def parse_ltp_response(ltp_response, exchange_segment:, security_id:)
1530
+ if ltp_response.is_a?(Hash) && ltp_response["data"]
1531
+ security_id_str = security_id.to_s
1532
+ ltp_data = ltp_response.dig("data", exchange_segment, security_id_str)
1533
+ ltp = extract_value(ltp_data, [:last_price, "last_price"]) if ltp_data
1534
+ return [ltp, ltp_data]
1535
+ end
1536
+
1537
+ if ltp_response.is_a?(Numeric)
1538
+ ltp = ltp_response
1539
+ return [ltp, { last_price: ltp }]
1540
+ end
1541
+
1542
+ ltp = extract_value(ltp_response, [:last_price, "last_price", :ltp, "ltp"]) || ltp_response
1543
+ [ltp, ltp_response]
1544
+ end
1545
+
1546
+ def valid_option_chain_inputs?(full_chain, last_price)
1547
+ return false unless full_chain.is_a?(Hash)
1548
+ return false if last_price.nil?
1549
+
1550
+ last_price.to_f.nonzero?
1551
+ end
1552
+
1553
+ def extract_chain_strikes(full_chain)
1554
+ strikes = full_chain.filter_map do |strike_key, strike_data|
1555
+ next unless strike_data.is_a?(Hash)
1556
+ next unless strike_has_both_sides?(strike_data)
1557
+
1558
+ strike_float = strike_key.to_f
1559
+ next unless strike_float.positive?
1560
+
1561
+ [strike_key, strike_float]
1562
+ end
1563
+ strikes.sort_by { |(_, price)| price }
1564
+ end
1565
+
1566
+ def strike_has_both_sides?(strike_data)
1567
+ (strike_data.key?(:ce) || strike_data.key?("ce")) &&
1568
+ (strike_data.key?(:pe) || strike_data.key?("pe"))
1569
+ end
1570
+
1571
+ def select_strike_keys(strikes, last_price, strikes_count)
1572
+ atm_strike = strikes.min_by { |_, price| (price - last_price).abs }
1573
+ return [] unless atm_strike
1574
+
1575
+ atm_index = strikes.index(atm_strike)
1576
+ return [] unless atm_index
1577
+
1578
+ total_strikes = [strikes_count.to_i, 1].max
1579
+ itm_count = (total_strikes - 1) / 2
1580
+ otm_count = total_strikes - 1 - itm_count
1581
+
1582
+ selected = []
1583
+ selected.concat(collect_strike_keys(strikes, atm_index - itm_count, atm_index - 1))
1584
+ selected << atm_strike[0]
1585
+ selected.concat(collect_strike_keys(strikes, atm_index + 1, atm_index + otm_count))
1586
+ selected
1587
+ end
1588
+
1589
+ def collect_strike_keys(strikes, start_idx, end_idx)
1590
+ return [] if start_idx > end_idx
1591
+
1592
+ bounded_start = [start_idx, 0].max
1593
+ bounded_end = [end_idx, strikes.length - 1].min
1594
+ return [] if bounded_start > bounded_end
1595
+
1596
+ strikes[bounded_start..bounded_end].map { |strike| strike[0] }
1597
+ end
1598
+
1599
+ def build_filtered_chain(full_chain, selected_strikes)
1600
+ selected_strikes.each_with_object({}) do |strike_key, filtered|
1601
+ filtered[strike_key] = full_chain[strike_key] if full_chain.key?(strike_key)
1602
+ end
1603
+ end
697
1604
  end
698
1605
  end
699
1606