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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +220 -12
- data/docs/CLOUD.md +29 -0
- data/docs/CONSOLE_IMPROVEMENTS.md +256 -0
- data/docs/FEATURES_ADDED.md +145 -0
- data/docs/HANDLERS_ANALYSIS.md +190 -0
- data/docs/README.md +37 -0
- data/docs/SCHEMA_FIXES.md +147 -0
- data/docs/TEST_UPDATES.md +107 -0
- data/examples/README.md +92 -0
- data/examples/advanced_complex_schemas.rb +6 -3
- data/examples/advanced_multi_step_agent.rb +13 -7
- data/examples/chat_console.rb +143 -0
- data/examples/complete_workflow.rb +14 -4
- data/examples/dhan_console.rb +843 -0
- data/examples/dhanhq/agents/base_agent.rb +0 -2
- data/examples/dhanhq/agents/orchestrator_agent.rb +1 -2
- data/examples/dhanhq/agents/technical_analysis_agent.rb +67 -49
- data/examples/dhanhq/analysis/market_structure.rb +44 -28
- data/examples/dhanhq/analysis/pattern_recognizer.rb +64 -47
- data/examples/dhanhq/analysis/trend_analyzer.rb +6 -8
- data/examples/dhanhq/dhanhq_agent.rb +296 -99
- data/examples/dhanhq/indicators/technical_indicators.rb +3 -5
- data/examples/dhanhq/scanners/intraday_options_scanner.rb +360 -255
- data/examples/dhanhq/scanners/swing_scanner.rb +118 -84
- data/examples/dhanhq/schemas/agent_schemas.rb +2 -2
- data/examples/dhanhq/services/data_service.rb +5 -7
- data/examples/dhanhq/services/trading_service.rb +0 -3
- data/examples/dhanhq/technical_analysis_agentic_runner.rb +217 -84
- data/examples/dhanhq/technical_analysis_runner.rb +216 -162
- data/examples/dhanhq/test_tool_calling.rb +538 -0
- data/examples/dhanhq/test_tool_calling_verbose.rb +251 -0
- data/examples/dhanhq/utils/trading_parameter_normalizer.rb +12 -17
- data/examples/dhanhq_agent.rb +159 -116
- data/examples/dhanhq_tools.rb +1158 -251
- data/examples/multi_step_agent_with_external_data.rb +368 -0
- data/examples/structured_tools.rb +89 -0
- data/examples/test_dhanhq_tool_calling.rb +375 -0
- data/examples/test_tool_calling.rb +160 -0
- data/examples/tool_calling_direct.rb +124 -0
- data/examples/tool_dto_example.rb +94 -0
- data/exe/dhan_console +4 -0
- data/exe/ollama-client +1 -1
- data/lib/ollama/agent/executor.rb +116 -15
- data/lib/ollama/client.rb +118 -55
- data/lib/ollama/config.rb +36 -0
- data/lib/ollama/dto.rb +187 -0
- data/lib/ollama/embeddings.rb +77 -0
- data/lib/ollama/options.rb +104 -0
- data/lib/ollama/response.rb +121 -0
- data/lib/ollama/tool/function/parameters/property.rb +72 -0
- data/lib/ollama/tool/function/parameters.rb +101 -0
- data/lib/ollama/tool/function.rb +78 -0
- data/lib/ollama/tool.rb +60 -0
- data/lib/ollama/version.rb +1 -1
- data/lib/ollama_client.rb +3 -0
- metadata +31 -3
- /data/{PRODUCTION_FIXES.md → docs/PRODUCTION_FIXES.md} +0 -0
- /data/{TESTING.md → docs/TESTING.md} +0 -0
data/examples/dhanhq_tools.rb
CHANGED
|
@@ -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
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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: "
|
|
113
|
-
error: "
|
|
114
|
-
params: {
|
|
166
|
+
action: "find_instrument",
|
|
167
|
+
error: "Symbol is required",
|
|
168
|
+
params: { symbol: symbol }
|
|
115
169
|
}
|
|
116
170
|
end
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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: "
|
|
139
|
-
params: {
|
|
200
|
+
return {
|
|
201
|
+
action: "find_instrument",
|
|
202
|
+
params: { symbol: symbol_str },
|
|
140
203
|
result: {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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: "
|
|
223
|
+
action: "find_instrument",
|
|
157
224
|
error: e.message,
|
|
158
|
-
params: {
|
|
225
|
+
params: { symbol: symbol }
|
|
159
226
|
}
|
|
160
227
|
end
|
|
161
228
|
|
|
162
|
-
#
|
|
163
|
-
#
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
178
|
-
instrument_symbol = instrument_symbol.to_s
|
|
299
|
+
rate_limit_marketfeed
|
|
179
300
|
exchange_segment = exchange_segment.to_s
|
|
180
301
|
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
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
|
-
#
|
|
238
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
307
|
-
#
|
|
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
|
-
#
|
|
312
|
-
|
|
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
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
#
|
|
354
|
-
resolved_instrument = if
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
#
|
|
464
|
-
#
|
|
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
|
|
467
|
-
#
|
|
468
|
-
|
|
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: "
|
|
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
|
-
#
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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: "
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
{
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
1029
|
+
)
|
|
552
1030
|
rescue StandardError => e
|
|
553
1031
|
{
|
|
554
1032
|
action: "get_option_chain",
|
|
555
1033
|
error: e.message,
|
|
556
|
-
params: {
|
|
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
|
-
#
|
|
562
|
-
#
|
|
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
|
-
#
|
|
568
|
-
|
|
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
|
|
582
|
-
if security_id
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|