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
@@ -0,0 +1,843 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ollama_client"
5
+ require "tty-reader"
6
+ require "tty-screen"
7
+ require "tty-cursor"
8
+ require "dhan_hq"
9
+ require "date"
10
+ require_relative "dhanhq_tools"
11
+
12
+ def build_config
13
+ config = Ollama::Config.new
14
+ config.base_url = ENV["OLLAMA_BASE_URL"] if ENV["OLLAMA_BASE_URL"]
15
+ config.model = ENV["OLLAMA_MODEL"] if ENV["OLLAMA_MODEL"]
16
+ config.temperature = ENV["OLLAMA_TEMPERATURE"].to_f if ENV["OLLAMA_TEMPERATURE"]
17
+ config
18
+ end
19
+
20
+ def exit_command?(text)
21
+ %w[/exit /quit exit quit].include?(text.downcase)
22
+ end
23
+
24
+ def system_prompt_from_env
25
+ system_prompt = ENV.fetch("OLLAMA_SYSTEM", nil)
26
+ return nil unless system_prompt && !system_prompt.strip.empty?
27
+
28
+ system_prompt
29
+ end
30
+
31
+ def print_banner(config)
32
+ puts "DhanHQ data console"
33
+ puts "Model: #{config.model}"
34
+ puts "Base URL: #{config.base_url}"
35
+ puts "Type /exit to quit."
36
+ puts "Screen: #{TTY::Screen.width}x#{TTY::Screen.height}"
37
+ puts
38
+ end
39
+
40
+ HISTORY_PATH = ".ollama_dhan_history"
41
+ MAX_HISTORY = 200
42
+ COLOR_RESET = "\e[0m"
43
+ COLOR_USER = "\e[32m"
44
+ COLOR_LLM = "\e[36m"
45
+ USER_PROMPT = "#{COLOR_USER}you>#{COLOR_RESET} ".freeze
46
+ LLM_PROMPT = "#{COLOR_LLM}llm>#{COLOR_RESET} ".freeze
47
+
48
+ def build_reader
49
+ TTY::Reader.new
50
+ end
51
+
52
+ def read_input(reader)
53
+ reader.read_line(USER_PROMPT)
54
+ end
55
+
56
+ def load_history(reader, path)
57
+ history = load_history_list(path)
58
+ history.reverse_each { |line| reader.add_to_history(line) }
59
+ end
60
+
61
+ def load_history_list(path)
62
+ return [] unless File.exist?(path)
63
+
64
+ unique_history(normalize_history(File.readlines(path, chomp: true)))
65
+ end
66
+
67
+ def normalize_history(lines)
68
+ lines.map(&:strip).reject(&:empty?)
69
+ end
70
+
71
+ def unique_history(lines)
72
+ seen = {}
73
+ lines.each_with_object([]) do |line, unique|
74
+ next if seen[line]
75
+
76
+ unique << line
77
+ seen[line] = true
78
+ end
79
+ end
80
+
81
+ def update_history(path, text)
82
+ history = load_history_list(path)
83
+ history.delete(text)
84
+ history.unshift(text)
85
+ history = history.first(MAX_HISTORY)
86
+
87
+ File.write(path, history.join("\n") + (history.empty? ? "" : "\n"))
88
+ end
89
+
90
+ def configure_dhanhq!
91
+ DhanHQ.configure_with_env
92
+ puts "✅ DhanHQ configured"
93
+ rescue StandardError => e
94
+ puts "❌ DhanHQ configuration error: #{e.message}"
95
+ puts " Make sure CLIENT_ID and ACCESS_TOKEN are set in ENV"
96
+ exit 1
97
+ end
98
+
99
+ def tool_system_prompt
100
+ <<~PROMPT
101
+ You are a market data assistant. Use tools to answer user queries completely.
102
+
103
+ CRITICAL: You are an EXECUTOR, not an INSTRUCTOR. When the user asks for data, you must EXECUTE tool calls to get that data, not describe how to get it.
104
+ Your job is to actually call the tools and provide the results, not to explain how the tools work.
105
+ DO NOT provide step-by-step instructions - actually execute the steps by making tool calls.
106
+ DO NOT show JSON examples - actually make the tool calls.
107
+
108
+ Available tools with REQUIRED parameters:
109
+
110
+ 1. find_instrument(symbol: String) - REQUIRED: symbol
111
+ - Find instrument details (exchange_segment, security_id) by symbol
112
+ - Use this FIRST when you only have a symbol and need to resolve it
113
+ - Returns: exchange_segment, security_id (numeric), trading_symbol, instrument_type
114
+
115
+ 2. get_market_quote(exchange_segment: String, symbol: String, security_id: Integer) - REQUIRED: exchange_segment, (symbol OR security_id)
116
+ - Get full market quote (OHLC, depth, volume, etc.)
117
+ - exchange_segment MUST be one of: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
118
+ - symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE")
119
+ - security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
120
+ - Rate limit: 1 request per second
121
+ - Up to 1000 instruments per request
122
+
123
+ 3. get_live_ltp(exchange_segment: String, symbol: String, security_id: Integer) - REQUIRED: exchange_segment, (symbol OR security_id)
124
+ - Get Last Traded Price (LTP) - fastest API for current price
125
+ - exchange_segment MUST be one of: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
126
+ - symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE")
127
+ - security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
128
+ - Rate limit: 1 request per second
129
+ - Up to 1000 instruments per request
130
+
131
+ 4. get_market_depth(exchange_segment: String, symbol: String, security_id: Integer) - REQUIRED: exchange_segment, (symbol OR security_id)
132
+ - Get full market depth (bid/ask levels, order book)
133
+ - exchange_segment MUST be one of: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
134
+ - symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE")
135
+ - security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
136
+ - Rate limit: 1 request per second
137
+ - Up to 1000 instruments per request
138
+
139
+ 5. get_historical_data(exchange_segment: String, from_date: String, to_date: String, symbol: String, security_id: Integer, interval: String, expiry_code: Integer, instrument: String, calculate_indicators: Boolean) - REQUIRED: exchange_segment, from_date, to_date, (symbol OR security_id)
140
+ - Get historical price data (OHLCV) OR technical indicators
141
+ - REQUIRED parameters:
142
+ * exchange_segment: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
143
+ * from_date: YYYY-MM-DD format - MUST be provided by user or calculated from user's request
144
+ * to_date: YYYY-MM-DD format (non-inclusive) - MUST be provided by user or calculated from user's request
145
+ * symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE") OR
146
+ * security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
147
+ - OPTIONAL parameters:
148
+ * interval: "1", "5", "15", "25", "60" (for intraday) - if provided, returns intraday data; if omitted, returns daily data
149
+ - CRITICAL FOR INTRADAY: If user asks for "intraday", "intraday movement", "intraday range", "today's movement", or similar:
150
+ * You MUST provide the interval parameter
151
+ * DEFAULT INTERVAL: Use "5" (5-minute) or "15" (15-minute) intervals - these are less noisy than 1-minute data
152
+ - Prefer "5" for most intraday analysis (good balance of detail and noise reduction)
153
+ - Use "15" for smoother, less noisy data
154
+ - Only use "1" if user explicitly requests 1-minute data
155
+ * DATE RULES FOR INTRADAY:
156
+ - to_date: MUST be TODAY's date: "#{Date.today.strftime("%Y-%m-%d")}"
157
+ - from_date: Can go back up to 30 days from today (max 30 days back)
158
+ * If user doesn't specify dates: Use from_date = "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date = "#{Date.today.strftime("%Y-%m-%d")}"
159
+ * If user asks for "today's movement": Use from_date = "#{Date.today.strftime("%Y-%m-%d")}", to_date = "#{Date.today.strftime("%Y-%m-%d")}"
160
+ * Maximum range: 30 days back from today
161
+ * "Intraday movement" or "intraday range" means the high-low price range for a specific day (usually today)
162
+ * Example: User asks "find the range of intraday movement for NIFTY" → Use interval: "5" (or "15"), from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{Date.today.strftime("%Y-%m-%d")}"
163
+ * Maximum 90 days of intraday data can be fetched at once
164
+ * expiry_code: 0 (far month), 1 (near month), 2 (current month) - for derivatives
165
+ * instrument: EQUITY, INDEX, FUTIDX, FUTSTK, OPTIDX, OPTSTK, FUTCOM, OPTFUT, FUTCUR, OPTCUR
166
+ * calculate_indicators: true/false (default: false) - If true, calculates and returns only technical indicators instead of raw data
167
+ When true, returns: RSI, MACD, SMA20, SMA50, EMA12, EMA26, Bollinger Bands, ATR, price range, volume stats
168
+ This significantly reduces response size and provides ready-to-use indicator values for analysis
169
+ - CRITICAL DATE RULES:
170
+ * NEVER invent or guess dates - dates MUST come from the user's explicit request OR use recent/current dates as default
171
+ * DEFAULT BEHAVIOR: If user doesn't specify dates, use RECENT/CURRENT dates (not old dates):
172
+ - For DAILY data (no interval): Use last 30 days from today (most recent data)
173
+ Example: If today is #{Date.today.strftime("%Y-%m-%d")}, use from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{(Date.today + 1).strftime("%Y-%m-%d")}"
174
+ - For INTRADAY data (with interval): to_date = TODAY's date, from_date can go back up to 30 days
175
+ Example: If today is #{Date.today.strftime("%Y-%m-%d")}, use from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{Date.today.strftime("%Y-%m-%d")}"
176
+ For "today's movement" specifically: from_date = "#{Date.today.strftime("%Y-%m-%d")}", to_date = "#{Date.today.strftime("%Y-%m-%d")}"
177
+ - This provides current/recent market data which is most useful for analysis
178
+ * ONLY use past/old dates if user explicitly requests them:
179
+ - If user says "January 2024" or "2024 data", then use those old dates
180
+ - If user says "historical data from 2023", then use 2023 dates
181
+ - But if user just says "historical data" without specifying, use RECENT dates (last 30 days)
182
+ * Relative time references:
183
+ - "last 30 days" → calculate from today backwards 30 days
184
+ - "last week" → calculate the actual dates for the past 7 days
185
+ - "current month" → use dates for the current month
186
+ - "this month" → use dates for the current month
187
+ * NEVER use random old dates like "2024-01-08" or "2024-02-07" unless the user explicitly requested those specific dates
188
+ * PREFER RECENT DATES: When in doubt, use recent dates (last 30 days) rather than old dates
189
+ - Notes: Maximum 90 days for intraday, data available for last 5 years
190
+
191
+ 6a. get_expiry_list(exchange_segment: String, symbol: String, security_id: Integer) - REQUIRED: exchange_segment, (symbol OR security_id)
192
+ - Get list of available expiry dates for an underlying instrument
193
+ - REQUIRED parameters:
194
+ * exchange_segment: For indices use "IDX_I", for stocks use "NSE_FNO" or "BSE_FNO"
195
+ * symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE") OR
196
+ * security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
197
+ - Returns: Array of available expiry dates in "YYYY-MM-DD" format
198
+ - For indices (NIFTY, BANKNIFTY): Use exchange_segment: "IDX_I" and INTEGER security_id from find_instrument (e.g., 13, not "NIFTY")
199
+ - Rate limit: 1 request per 3 seconds
200
+ - Use this tool first to get available expiries before calling get_option_chain
201
+
202
+ 6. get_option_chain(exchange_segment: String, symbol: String, security_id: Integer, expiry: String, strikes_count: Integer) - REQUIRED: exchange_segment, (symbol OR security_id), expiry
203
+ - Get option chain for an underlying instrument for a specific expiry
204
+ - REQUIRED parameters:
205
+ * exchange_segment: For indices use "IDX_I", for stocks use "NSE_FNO" or "BSE_FNO"
206
+ * symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE") OR
207
+ * security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
208
+ * expiry: YYYY-MM-DD format (REQUIRED - use get_expiry_list first to get available expiry dates)
209
+ - OPTIONAL parameters:
210
+ * strikes_count: Number of strikes to return around ATM (default: 5)
211
+ - 3 strikes: 1 ITM, ATM, 1 OTM (minimal overview)
212
+ - 5 strikes: 2 ITM, ATM, 2 OTM (recommended default - good for analysis)
213
+ - 7 strikes: 3 ITM, ATM, 3 OTM (more detailed analysis)
214
+ - 10+ strikes: More comprehensive view (larger response)
215
+ - Returns: Option chain filtered to show strikes around ATM (ITM, ATM, OTM)
216
+ - Note: Chain is automatically filtered to reduce response size - only strikes with both CE and PE are included
217
+ - For indices (NIFTY, BANKNIFTY): Use exchange_segment: "IDX_I" and INTEGER security_id from find_instrument (e.g., 13, not "NIFTY")
218
+ - Rate limit: 1 request per 3 seconds
219
+ - CRITICAL WORKFLOW: If user asks for "next expiry", "upcoming expiry", "nearest expiry", or similar:
220
+ 1. First call get_expiry_list to get the expiry list
221
+ 2. Extract the FIRST expiry date from result.expiries array (this is the next/upcoming expiry)
222
+ 3. Use the EXACT expiry date string from result.expiries[0] - DO NOT invent, guess, or modify the date
223
+ 4. Call get_option_chain with that EXACT expiry date to get the actual option chain data
224
+ 5. Do NOT stop after getting the expiry list - you MUST fetch the actual chain data
225
+ - Example: User asks "option chain of NIFTY for next expiry"
226
+ Step 1: get_expiry_list(exchange_segment: "IDX_I", security_id: 13) → returns {result: {expiries: ["2026-01-20", "2026-01-27", ...]}}
227
+ Step 2: get_option_chain(exchange_segment: "IDX_I", security_id: 13, expiry: "2026-01-20") → use the EXACT first date from the list
228
+ - CRITICAL: If the expiry list shows ["2026-01-20", ...], use "2026-01-20", NOT "2024-12-26" or any other date you might think is correct
229
+ - NEVER invent or guess expiry dates - ALWAYS copy the exact date string from result.expiries array
230
+
231
+ 7. get_expired_options_data(exchange_segment: String, expiry_date: String, symbol: String, security_id: Integer, interval: String, instrument: String, expiry_flag: String, expiry_code: Integer, strike: String, drv_option_type: String, required_data: Array) - REQUIRED: exchange_segment, expiry_date, (symbol OR security_id)
232
+ - Get historical expired options data
233
+ - REQUIRED parameters:
234
+ * exchange_segment: NSE_FNO, BSE_FNO, NSE_EQ, BSE_EQ
235
+ * expiry_date: YYYY-MM-DD format
236
+ * symbol: Trading symbol string (e.g., "NIFTY", "RELIANCE") OR
237
+ * security_id: MUST be an INTEGER (e.g., 13, 2885) - NEVER use a symbol string as security_id
238
+ - OPTIONAL parameters (with defaults):
239
+ * interval: "1", "5", "15", "25", "60" (default: "1")
240
+ * instrument: "OPTIDX" (Index Options) or "OPTSTK" (Stock Options) - auto-detected if not provided
241
+ * expiry_flag: "WEEK" or "MONTH" (default: "MONTH")
242
+ * expiry_code: 0 (far), 1 (near), 2 (current) - default: 1 (near month)
243
+ * strike: "ATM", "ATM+X", "ATM-X" (default: "ATM") - up to ATM+10/ATM-10 for index options, ATM+3/ATM-3 for others
244
+ * drv_option_type: "CALL" or "PUT" (default: "CALL")
245
+ * required_data: Array of fields like ["open", "high", "low", "close", "iv", "volume", "strike", "oi", "spot"] (default: all fields)
246
+ - Notes: Maximum 31 days of data per request, historical data available for last 5 years
247
+
248
+ EXCHANGE SEGMENTS (valid values):
249
+ - IDX_I - Index
250
+ - NSE_EQ - NSE Equity Cash
251
+ - NSE_FNO - NSE Futures & Options
252
+ - NSE_CURRENCY - NSE Currency
253
+ - BSE_EQ - BSE Equity Cash
254
+ - BSE_FNO - BSE Futures & Options
255
+ - BSE_CURRENCY - BSE Currency
256
+ - MCX_COMM - MCX Commodity
257
+
258
+ INSTRUMENT TYPES (valid values):
259
+ - EQUITY - Equity
260
+ - INDEX - Index
261
+ - FUTIDX - Futures Index
262
+ - FUTSTK - Futures Stock
263
+ - OPTIDX - Options Index
264
+ - OPTSTK - Options Stock
265
+ - FUTCOM - Futures Commodity
266
+ - OPTFUT - Options Futures
267
+ - FUTCUR - Futures Currency
268
+ - OPTCUR - Options Currency
269
+
270
+ WORKFLOW:
271
+ - CRITICAL: You are an EXECUTOR, not an INSTRUCTOR. You must EXECUTE tool calls, not describe how to use them.
272
+ - When you need to call multiple tools (e.g., find_instrument then get_live_ltp), call them in sequence.
273
+ - After each tool call, use the EXACT values from the tool result to continue. Do not stop until you have fully answered the user's query.
274
+ - CRITICAL: You MUST continue making tool calls until you get results and can fully answer the user's query.
275
+ * Do NOT stop after describing what you would do - you MUST actually do it
276
+ * Do NOT stop after showing a JSON example - you MUST actually make the tool call
277
+ * Keep making tool calls until you have the data needed to answer the user's question
278
+ * After getting results, use them to provide a complete answer to the user
279
+ * The user's query is NOT answered until you provide the actual data/answer, not just instructions
280
+ * If a tool call fails, immediately make another tool call with corrected parameters - do NOT stop
281
+ * Continue the tool calling loop until you have successfully retrieved data and can answer the user
282
+ * Example: User asks "find range of intraday movement" → Call find_instrument → Call get_historical_data → Calculate range from results → Provide answer with actual range value
283
+ - CRITICAL: You MUST actually execute tool calls, not just describe them. The system will automatically execute any tool calls you make.
284
+ * If you describe a tool call instead of making it, the user's question will not be answered
285
+ * Make the tool call, wait for results, then use those results to provide a complete answer
286
+ * If a tool call fails, fix the parameters and ACTUALLY retry - do NOT just describe the fix
287
+ - CRITICAL: NEVER invent, guess, or modify values from tool results. ALWAYS use the exact values as returned.
288
+ - If you called find_instrument and got a result like: {"result": {"exchange_segment": "IDX_I", "security_id": "13", "symbol": "NIFTY"}}
289
+ Then for the next tool call, use:
290
+ - exchange_segment: "IDX_I" (the exact string from result.exchange_segment)
291
+ - security_id: 13 (the INTEGER value from result.security_id - convert string "13" to integer 13)
292
+ - Do NOT use the symbol string "NIFTY" as security_id - security_id must be an INTEGER
293
+ - Example: If find_instrument returns {"result": {"exchange_segment": "IDX_I", "security_id": "13", "symbol": "NIFTY"}}
294
+ Then call get_option_chain with: exchange_segment: "IDX_I", security_id: 13 (integer, NOT the string "NIFTY" or "13")
295
+ - CRITICAL: security_id must ALWAYS be an INTEGER (e.g., 13, 2885), NEVER a symbol string (e.g., "NIFTY", "RELIANCE")
296
+ - CRITICAL: If result.security_id is a string (e.g., "13"), convert it to integer (13) before using as security_id parameter
297
+ - CRITICAL: If get_expiry_list returns {"result": {"expiries": ["2026-01-20", "2026-01-27", ...]}}, use "2026-01-20" (the first date), NOT "2024-12-26" or any other date
298
+ - NEVER invent dates, expiry values, or any other data - ALWAYS use exact values from tool results
299
+ - CRITICAL FOR OPTION CHAIN: If user asks for "next expiry", "upcoming expiry", "nearest expiry", or "option chain" without specifying expiry:
300
+ 1. First call get_expiry_list to get the list of available expiry dates
301
+ 2. Extract the FIRST expiry date from result.expiries array (this is the next/upcoming expiry)
302
+ 3. Use the EXACT expiry date string from result.expiries[0] - DO NOT invent, guess, or modify the date
303
+ 4. Call get_option_chain with that EXACT expiry date to get the actual option chain data
304
+ 5. DO NOT stop after getting just the expiry list - you MUST fetch the actual chain data to answer the user's query
305
+ 6. Only provide your final answer after you have the actual option chain data, not just the expiry list
306
+ 7. CRITICAL EXAMPLE: If get_expiry_list returns {"result": {"expiries": ["2026-01-20", "2026-01-27", ...]}},
307
+ you MUST use "2026-01-20" (the first date in the array), NOT "2024-12-26" or any other date
308
+ 8. NEVER invent or guess expiry dates - ALWAYS copy the exact date string from result.expiries array
309
+ 9. If the expiry list shows dates starting in 2026, use a 2026 date, NOT a 2024 date
310
+ - Only provide your final answer after you have all the data needed to answer the user's question.
311
+ - Indices like NIFTY, BANKNIFTY DO have options - they are index options. Use exchange_segment: IDX_I and the numeric security_id from find_instrument.
312
+
313
+ CRITICAL RULES:
314
+ - If the user provides ONLY a symbol (e.g., "RELIANCE", "TCS", "ltp of RELIANCE") WITHOUT exchange_segment, you MUST call find_instrument FIRST to get the correct exchange_segment and security_id.
315
+ - If the user ALREADY provides exchange_segment (e.g., "get_market_quote for RELIANCE on exchange_segment NSE_EQ"), you can call the data tool directly - find_instrument is NOT needed.
316
+ - NEVER guess or invent exchange_segment values. Valid values are: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
317
+ - NEVER use "NSE" or "BSE" alone - they are invalid. Always use full values like "NSE_EQ" or "BSE_EQ".
318
+ - Common indices (NIFTY, BANKNIFTY, FINNIFTY, etc.) are found in IDX_I, not NSE_EQ.
319
+ - DATE FORMATS: Always use YYYY-MM-DD format (e.g., "#{Date.today.strftime("%Y-%m-%d")}", not "01/08/2024" or "08-Jan-2024")
320
+ - TECHNICAL ANALYSIS: For technical analysis requests, use get_historical_data with calculate_indicators: true
321
+ This returns only calculated indicator values (RSI, MACD, SMA, EMA, Bollinger Bands, ATR) instead of raw data
322
+ This makes the response much smaller and easier to analyze
323
+ Example: get_historical_data(exchange_segment: "NSE_EQ", symbol: "RELIANCE", from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{(Date.today + 1).strftime("%Y-%m-%d")}", calculate_indicators: true)
324
+ - For historical data, to_date is NON-INCLUSIVE (end date is not included in results)
325
+ - CRITICAL DATE HANDLING FOR HISTORICAL DATA:
326
+ * NEVER invent, guess, or randomly choose dates for get_historical_data
327
+ * DEFAULT: Use RECENT/CURRENT dates when user doesn't specify dates:
328
+ - For DAILY data (no interval): Use last 30 days from today
329
+ - For INTRADAY data (with interval): Use TODAY's date (from_date = today, to_date = today + 1)
330
+ * ONLY use past/old dates if user explicitly requests them (e.g., "January 2024", "2023 data", "data from 2022")
331
+ * Date calculation rules:
332
+ * If user doesn't specify dates:
333
+ - For DAILY data (no interval): Use last 30 days from today (RECENT data)
334
+ Example: If today is #{Date.today.strftime("%Y-%m-%d")}, use from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{(Date.today + 1).strftime("%Y-%m-%d")}"
335
+ - For INTRADAY data (with interval): to_date = TODAY's date, from_date can go back up to 30 days (max)
336
+ Example: If today is #{Date.today.strftime("%Y-%m-%d")}, use from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{Date.today.strftime("%Y-%m-%d")}"
337
+ For "today's movement" specifically: from_date = "#{Date.today.strftime("%Y-%m-%d")}", to_date = "#{Date.today.strftime("%Y-%m-%d")}"
338
+ Maximum range: 30 days back from today
339
+ * If user says "last 30 days" → Calculate: from_date = today - 30 days, to_date = today + 1 day (since to_date is non-inclusive)
340
+ * If user says "last week" → Calculate the actual dates for the past 7 days
341
+ * If user says "current month" or "this month" → Use dates for the current month
342
+ * If user says "January 2024" (old date) → Use from_date: "2024-01-01", to_date: "2024-02-01" (only if explicitly requested)
343
+ * If user says "past month" → Calculate from today backwards 30 days (recent, not old)
344
+ * CRITICAL FOR INTRADAY: When user asks for "intraday movement", "intraday range", "today's movement", or similar:
345
+ - You MUST provide interval parameter (prefer "5" or "15" for less noisy data, only use "1" if explicitly requested)
346
+ - to_date: MUST be TODAY's date: "#{Date.today.strftime("%Y-%m-%d")}"
347
+ - from_date: Can go back up to 30 days from today (max 30 days back)
348
+ * Default: from_date = "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date = "#{Date.today.strftime("%Y-%m-%d")}"
349
+ * For "today's movement": from_date = "#{Date.today.strftime("%Y-%m-%d")}", to_date = "#{Date.today.strftime("%Y-%m-%d")}"
350
+ - Do NOT use old dates like "2023-01-01" unless user explicitly requests them
351
+ * PREFER RECENT DATES: When user says "historical data" without dates, use last 30 days (recent), NOT old dates like "2024-01-08"
352
+ * NEVER use random old dates like "2024-01-08" to "2024-02-07" unless the user explicitly requested those specific dates
353
+ * Example: If today is #{Date.today.strftime("%Y-%m-%d")}:
354
+ - User says "historical data for RELIANCE" → Use last 30 days: from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{(Date.today + 1).strftime("%Y-%m-%d")}" (RECENT)
355
+ - User says "historical data for RELIANCE from January 2024" → Use old dates: from_date: "2024-01-01", to_date: "2024-02-01" (OLD, explicitly requested)
356
+ - ERROR HANDLING: If a tool returns an error about invalid exchange_segment or missing required parameters, you MUST:
357
+ 1. Call find_instrument(symbol) to get the correct exchange_segment and security_id - ACTUALLY call it, don't describe it
358
+ 2. Verify all required parameters are provided with correct formats
359
+ 3. ACTUALLY RETRY the original tool call with the resolved parameters - do NOT just describe what you would do
360
+ 4. After the retry succeeds, use the results to answer the user's question completely
361
+ 5. If an error occurs, fix the parameters and ACTUALLY call the tool again - do NOT stop after describing the fix
362
+ 6. CRITICAL: When you see an error, you MUST make another tool call to fix it - do NOT output text describing what you would do
363
+ 7. CONTINUE making tool calls until you get successful results - do NOT stop after one failed attempt
364
+ 8. After getting successful results, use them to calculate/derive the answer and provide it to the user
365
+ 9. Example of WRONG behavior: "Here is how you can do it: [JSON example]" - this does NOT execute the tool and does NOT answer the user
366
+ 10. Example of CORRECT behavior: Actually make the tool call, get results, calculate the answer from results, then provide the answer to the user
367
+ - CRITICAL: When you need to call a tool, you MUST actually CALL it using the tool_calls mechanism.
368
+ * YOU ARE AN EXECUTOR, NOT AN INSTRUCTOR - execute tool calls, do not describe them
369
+ * DO NOT just describe what you would call in text
370
+ * DO NOT output JSON code blocks showing tool calls
371
+ * DO NOT say "Here is the corrected code:" or "We can call the tool with..." or "Here is how you can do it:"
372
+ * DO NOT show example JSON - the system handles tool calls automatically
373
+ * DO NOT provide step-by-step instructions - actually execute the steps
374
+ * DO NOT say "1. Call find_instrument..." - actually CALL find_instrument
375
+ * YOU MUST actually make the tool call - the system will execute it automatically
376
+ * After the tool executes, use the results to answer the user's question completely
377
+ * Do NOT stop after describing what you would do - actually DO it
378
+ * If a tool call fails, fix the parameters and ACTUALLY call it again - do NOT just describe the fix
379
+ * Your response should contain the actual tool calls, not descriptions of tool calls
380
+ * CONTINUE making tool calls until you have the data needed to answer the user's query
381
+ * Do NOT stop until you have provided the actual answer/data to the user, not just instructions on how to get it
382
+ * The tool calling loop should continue until you have successfully retrieved data and calculated the answer
383
+ * Example workflow: User asks "find range" → Tool call 1 (find_instrument) → Tool call 2 (get_historical_data) → Calculate range from data → Provide answer: "The intraday range is X to Y"
384
+ * REMEMBER: If you describe tool calls instead of making them, the user's question will NOT be answered
385
+ - Only call a tool when the user explicitly asks for market data, prices, quotes, option chains, or historical data.
386
+ - If the user is greeting, chatting, or asking a general question, respond normally without calling tools.
387
+ - When tools are used, fetch real data; do not invent values.
388
+ PROMPT
389
+ end
390
+
391
+ def planning_system_prompt
392
+ <<~PROMPT
393
+ You are a planning assistant for a market data console.
394
+ Analyze the user query and decide if tools are required to answer it.
395
+
396
+ CRITICAL: If the query mentions any of these keywords or patterns, tools ARE needed:
397
+ - "get_market_quote", "market quote", "quote"
398
+ - "get_live_ltp", "LTP", "last traded price", "current price"
399
+ - "get_market_depth", "market depth", "depth"
400
+ - "get_historical_data", "historical", "price history", "candles", "OHLC", "intraday", "intraday movement", "intraday range", "today's movement"
401
+ - "get_option_chain", "option chain", "options chain"
402
+ - "get_expired_options", "expired options"
403
+ - Any stock symbol (RELIANCE, TCS, INFY, etc.) with a request for data
404
+ - Any request for real-time or historical market data
405
+
406
+ Examples that NEED tools:
407
+ - "get_market_quote for RELIANCE" → needs_tools: true, tools: [find_instrument, get_market_quote] (no exchange_segment provided)
408
+ - "get_market_quote for RELIANCE on exchange_segment NSE_EQ" → needs_tools: true, tools: [get_market_quote] (exchange_segment already provided, skip find_instrument)
409
+ - "What is the LTP of TCS?" → needs_tools: true, tools: [find_instrument, get_live_ltp] (no exchange_segment provided)
410
+ - "ltp of RELIANCE" → needs_tools: true, tools: [find_instrument, get_live_ltp] (no exchange_segment provided)
411
+ - "Show me historical data for INFY from 2024-01-01 to 2024-01-31" → needs_tools: true, tools: [find_instrument, get_historical_data] (user provided explicit old dates - use them)
412
+ - "Show me last 30 days of RELIANCE" → needs_tools: true, tools: [find_instrument, get_historical_data] (calculate dates from today: from_date = "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date = "#{(Date.today + 1).strftime("%Y-%m-%d")}")
413
+ - "Get historical data for NIFTY" → needs_tools: true, tools: [find_instrument, get_historical_data] (use DEFAULT: last 30 days from today - RECENT dates, not old dates)
414
+ - "Historical data for RELIANCE" → needs_tools: true, tools: [find_instrument, get_historical_data] (use DEFAULT: last 30 days - RECENT dates)
415
+ - "Find the range of intraday movement for NIFTY" → needs_tools: true, tools: [find_instrument, get_historical_data] (MUST use interval: "5" or "15" - prefer "5" for less noise, from_date: "#{(Date.today - 30).strftime("%Y-%m-%d")}", to_date: "#{Date.today.strftime("%Y-%m-%d")}" - up to 30 days back, ending today)
416
+ - "Today's intraday movement for RELIANCE" → needs_tools: true, tools: [find_instrument, get_historical_data] (MUST use interval: "5" or "15" - prefer "5", from_date: "#{Date.today.strftime("%Y-%m-%d")}", to_date: "#{Date.today.strftime("%Y-%m-%d")}" - today only)
417
+ - "Get data from January 2024 for NIFTY" → needs_tools: true, tools: [find_instrument, get_historical_data] (user explicitly requested old dates - use "2024-01-01" to "2024-02-01")
418
+ - "Get option chain for NIFTY" → needs_tools: true, tools: [find_instrument, get_option_chain] (no exchange_segment provided)
419
+
420
+ REQUIRED PARAMETERS SUMMARY:
421
+ - find_instrument: REQUIRED - symbol
422
+ - get_market_quote: REQUIRED - exchange_segment, (symbol OR security_id)
423
+ - get_live_ltp: REQUIRED - exchange_segment, (symbol OR security_id)
424
+ - get_market_depth: REQUIRED - exchange_segment, (symbol OR security_id)
425
+ - get_historical_data: REQUIRED - exchange_segment, from_date (YYYY-MM-DD), to_date (YYYY-MM-DD), (symbol OR security_id); OPTIONAL - calculate_indicators (true/false) to return only indicator values instead of raw data
426
+ - get_option_chain: REQUIRED - exchange_segment, (symbol OR security_id); OPTIONAL - expiry (YYYY-MM-DD)
427
+ - get_expired_options_data: REQUIRED - exchange_segment, expiry_date (YYYY-MM-DD), (symbol OR security_id)
428
+
429
+ CRITICAL RULES:
430
+ - This is a PLANNING step - after planning, you MUST actually execute the tool calls in your response
431
+ - Do NOT just plan and describe - you MUST actually make the tool calls
432
+ - If the user provides ONLY a symbol (like "RELIANCE", "TCS", "NIFTY") WITHOUT exchange_segment, the workflow MUST be:
433
+ 1. First call find_instrument(symbol) to get exchange_segment and security_id - ACTUALLY call it
434
+ 2. Then call the data tool (get_live_ltp, get_market_quote, etc.) with the resolved exchange_segment - ACTUALLY call it
435
+ - If the user ALREADY provides exchange_segment (e.g., "on exchange_segment NSE_EQ"), you can call the data tool directly - find_instrument is NOT needed.
436
+ - After planning, your response MUST contain actual tool calls, not descriptions of tool calls
437
+ - For historical data, user MUST provide from_date and to_date in YYYY-MM-DD format
438
+ - For option chain, if user provides expiry date, it must be in YYYY-MM-DD format
439
+ - For expired options data, user MUST provide expiry_date in YYYY-MM-DD format
440
+
441
+ Valid exchange_segment values: NSE_EQ, NSE_FNO, NSE_CURRENCY, BSE_EQ, BSE_FNO, BSE_CURRENCY, MCX_COMM, IDX_I
442
+ NEVER use "NSE" or "BSE" alone - they are invalid. Use "NSE_EQ" or "BSE_EQ" instead.
443
+ Common indices (NIFTY, BANKNIFTY, FINNIFTY, etc.) use IDX_I, not NSE_EQ.
444
+
445
+ Examples that DO NOT need tools:
446
+ - "Hi", "Hello", "How are you?" → needs_tools: false
447
+ - "What is a stock?" → needs_tools: false (general knowledge)
448
+ - "Explain options trading" → needs_tools: false (educational)
449
+
450
+ If tools are needed, specify which tool(s) and what parameters are required, including date formats (YYYY-MM-DD).
451
+ PROMPT
452
+ end
453
+
454
+ def planning_schema
455
+ {
456
+ "type" => "object",
457
+ "required" => ["needs_tools", "reasoning", "tool_requests"],
458
+ "properties" => {
459
+ "needs_tools" => { "type" => "boolean" },
460
+ "reasoning" => { "type" => "string" },
461
+ "tool_requests" => {
462
+ "type" => "array",
463
+ "items" => {
464
+ "type" => "object",
465
+ "required" => ["name", "purpose"],
466
+ "properties" => {
467
+ "name" => { "type" => "string" },
468
+ "purpose" => { "type" => "string" },
469
+ "args_hint" => { "type" => "string" }
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ end
476
+
477
+ def build_tools
478
+ {
479
+ "find_instrument" => lambda do |symbol:|
480
+ DhanHQDataTools.find_instrument(**compact_kwargs(symbol: symbol))
481
+ end,
482
+ "get_market_quote" => lambda do |exchange_segment:, symbol: nil, security_id: nil|
483
+ DhanHQDataTools.get_market_quote(**compact_kwargs(exchange_segment: exchange_segment,
484
+ symbol: symbol,
485
+ security_id: security_id))
486
+ end,
487
+ "get_live_ltp" => lambda do |exchange_segment:, symbol: nil, security_id: nil|
488
+ DhanHQDataTools.get_live_ltp(**compact_kwargs(exchange_segment: exchange_segment,
489
+ symbol: symbol,
490
+ security_id: security_id))
491
+ end,
492
+ "get_market_depth" => lambda do |exchange_segment:, symbol: nil, security_id: nil|
493
+ DhanHQDataTools.get_market_depth(**compact_kwargs(exchange_segment: exchange_segment,
494
+ symbol: symbol,
495
+ security_id: security_id))
496
+ end,
497
+ "get_historical_data" => lambda do |exchange_segment:, from_date:, to_date:, symbol: nil, security_id: nil,
498
+ interval: nil, expiry_code: nil, calculate_indicators: false|
499
+ # Convert security_id to integer if provided (LLM may pass it as string)
500
+ normalized_security_id = security_id&.to_i
501
+ DhanHQDataTools.get_historical_data(**compact_kwargs(exchange_segment: exchange_segment,
502
+ symbol: symbol,
503
+ security_id: normalized_security_id,
504
+ from_date: from_date,
505
+ to_date: to_date,
506
+ interval: interval,
507
+ expiry_code: expiry_code,
508
+ calculate_indicators: calculate_indicators))
509
+ end,
510
+ "get_expiry_list" => lambda do |exchange_segment:, symbol: nil, security_id: nil|
511
+ # Convert security_id to integer if provided (LLM may pass it as string)
512
+ normalized_security_id = security_id&.to_i
513
+ DhanHQDataTools.get_expiry_list(**compact_kwargs(exchange_segment: exchange_segment,
514
+ symbol: symbol,
515
+ security_id: normalized_security_id))
516
+ end,
517
+ "get_option_chain" => lambda do |exchange_segment:, symbol: nil, security_id: nil, expiry: nil, strikes_count: 5|
518
+ # Convert security_id to integer if provided (LLM may pass it as string)
519
+ normalized_security_id = security_id&.to_i
520
+ # Default to 5 strikes (2 ITM, ATM, 2 OTM) - good balance for analysis
521
+ normalized_strikes_count = strikes_count.to_i
522
+ normalized_strikes_count = 5 if normalized_strikes_count < 1 # Minimum 1 (ATM)
523
+ DhanHQDataTools.get_option_chain(**compact_kwargs(exchange_segment: exchange_segment,
524
+ symbol: symbol,
525
+ security_id: normalized_security_id,
526
+ expiry: expiry,
527
+ strikes_count: normalized_strikes_count))
528
+ end,
529
+ "get_expired_options_data" => lambda do |exchange_segment:, expiry_date:, symbol: nil, security_id: nil,
530
+ interval: nil, instrument: nil, expiry_flag: nil, expiry_code: nil,
531
+ strike: nil, drv_option_type: nil, required_data: nil|
532
+ DhanHQDataTools.get_expired_options_data(
533
+ **compact_kwargs(exchange_segment: exchange_segment,
534
+ expiry_date: expiry_date,
535
+ symbol: symbol,
536
+ security_id: security_id,
537
+ interval: interval,
538
+ instrument: instrument,
539
+ expiry_flag: expiry_flag,
540
+ expiry_code: expiry_code,
541
+ strike: strike,
542
+ drv_option_type: drv_option_type,
543
+ required_data: required_data)
544
+ )
545
+ end
546
+ }
547
+ end
548
+
549
+ def compact_kwargs(kwargs)
550
+ kwargs.reject { |_, value| value.nil? || value == "" }
551
+ end
552
+
553
+ def tool_messages(messages)
554
+ messages.select { |message| message[:role] == "tool" }
555
+ end
556
+
557
+ def print_tool_results(messages)
558
+ tool_messages(messages).each do |message|
559
+ print_tool_message(message)
560
+ end
561
+ puts # blank line after tool results
562
+ end
563
+
564
+ def print_tool_message(message)
565
+ tool_name = message[:name] || "unknown_tool"
566
+ content = parse_tool_content(message[:content])
567
+
568
+ puts "\n#{COLOR_LLM}🔧 Tool Called:#{COLOR_RESET} #{tool_name}"
569
+ print_formatted_result(tool_name, content)
570
+ end
571
+
572
+ def format_tool_content(content)
573
+ parsed = parse_tool_content(content)
574
+ return parsed if parsed.is_a?(String)
575
+
576
+ JSON.pretty_generate(parsed)
577
+ end
578
+
579
+ def parse_tool_content(content)
580
+ return content unless content.is_a?(String)
581
+
582
+ JSON.parse(content)
583
+ rescue JSON::ParserError
584
+ content
585
+ end
586
+
587
+ def print_formatted_result(tool_name, content)
588
+ return puts content if content.is_a?(String)
589
+
590
+ result = content["result"] || content
591
+
592
+ case tool_name
593
+ when "get_live_ltp"
594
+ print_ltp_result(result)
595
+ when "get_market_quote"
596
+ print_quote_result(result)
597
+ when "get_historical_data"
598
+ print_historical_result(result, content)
599
+ when "get_option_chain"
600
+ print_option_chain_result(result)
601
+ when "get_expiry_list"
602
+ print_expiry_list_result(result)
603
+ when "find_instrument"
604
+ print_instrument_result(result)
605
+ else
606
+ puts " #{COLOR_LLM}→#{COLOR_RESET} #{JSON.pretty_generate(content)}"
607
+ end
608
+ end
609
+
610
+ def print_ltp_result(result)
611
+ symbol = result["symbol"] || "Unknown"
612
+ ltp = result["ltp"] || result.dig("ltp_data", "last_price")
613
+ exchange = result["exchange_segment"]
614
+
615
+ puts " #{COLOR_LLM}→#{COLOR_RESET} #{symbol} (#{exchange})"
616
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Last Price: ₹#{ltp}"
617
+ end
618
+
619
+ def print_quote_result(result)
620
+ symbol = result["symbol"] || "Unknown"
621
+ quote = result["quote"] || {}
622
+ ohlc = quote["ohlc"] || {}
623
+
624
+ puts " #{COLOR_LLM}→#{COLOR_RESET} #{symbol}"
625
+ puts " #{COLOR_LLM}→#{COLOR_RESET} LTP: ₹#{quote['last_price']}"
626
+ puts " #{COLOR_LLM}→#{COLOR_RESET} OHLC: O:#{ohlc['open']} H:#{ohlc['high']} L:#{ohlc['low']} C:#{ohlc['close']}"
627
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Volume: #{quote['volume']}" if quote["volume"]&.positive?
628
+ end
629
+
630
+ def print_historical_result(result, content)
631
+ if result.is_a?(Hash) && result.key?("indicators")
632
+ print_indicator_result(result)
633
+ elsif result.is_a?(Hash)
634
+ data_points = result["data"]&.size || 0
635
+ interval = content.dig("params", "interval")
636
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Historical data: #{data_points} records"
637
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Interval: #{interval}" if interval
638
+ elsif result.is_a?(Array)
639
+ puts " #{COLOR_LLM}→#{COLOR_RESET} #{result.size} data points"
640
+ end
641
+ end
642
+
643
+ def print_indicator_result(result)
644
+ indicators = result["indicators"]
645
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Technical Indicators:"
646
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Current Price: ₹#{indicators['current_price']}"
647
+ puts " #{COLOR_LLM}→#{COLOR_RESET} RSI(14): #{indicators['rsi']&.round(2)}"
648
+ puts " #{COLOR_LLM}→#{COLOR_RESET} MACD: #{indicators.dig('macd', 'macd')&.round(2)}"
649
+ puts " #{COLOR_LLM}→#{COLOR_RESET} SMA(20): ₹#{indicators['sma_20']&.round(2)}"
650
+ puts " #{COLOR_LLM}→#{COLOR_RESET} SMA(50): ₹#{indicators['sma_50']&.round(2)}"
651
+ end
652
+
653
+ def print_option_chain_result(result)
654
+ oc = result.dig("data", "oc") || {}
655
+ last_price = result.dig("data", "last_price")
656
+ strikes = oc.keys.size
657
+
658
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Spot: ₹#{last_price}"
659
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Strikes: #{strikes}"
660
+ puts " #{COLOR_LLM}→#{COLOR_RESET} (Filtered: ATM/OTM/ITM with both CE & PE)"
661
+ end
662
+
663
+ def print_expiry_list_result(result)
664
+ expiries = result["expiries"] || []
665
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Available expiries: #{expiries.size}"
666
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Next expiry: #{expiries.first}" if expiries.any?
667
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Expiries: #{expiries[0..4].join(', ')}#{'...' if expiries.size > 5}"
668
+ end
669
+
670
+ def print_instrument_result(result)
671
+ symbol = result["symbol"]
672
+ security_id = result["security_id"]
673
+ exchange = result["exchange_segment"]
674
+
675
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Found: #{symbol}"
676
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Security ID: #{security_id}"
677
+ puts " #{COLOR_LLM}→#{COLOR_RESET} Exchange: #{exchange}"
678
+ end
679
+
680
+ def show_llm_summary?
681
+ ENV["SHOW_LLM_SUMMARY"] == "true"
682
+ end
683
+
684
+ def allow_no_tool_output?
685
+ ENV["ALLOW_NO_TOOL_OUTPUT"] != "false"
686
+ end
687
+
688
+ def show_plan?
689
+ ENV["SHOW_PLAN"] == "true"
690
+ end
691
+
692
+ def print_hallucination_warning
693
+ puts "No tool results were produced."
694
+ puts "LLM output suppressed to avoid hallucinated data."
695
+ end
696
+
697
+ def query_requires_tools?(query)
698
+ tool_keywords = [
699
+ "get_market_quote", "market quote", "quote",
700
+ "get_live_ltp", "ltp", "last traded price", "current price",
701
+ "get_market_depth", "market depth", "depth",
702
+ "get_historical_data", "historical", "price history", "candles", "ohlc",
703
+ "get_option_chain", "option chain", "options chain",
704
+ "get_expired_options", "expired options"
705
+ ]
706
+
707
+ query_lower = query.downcase
708
+ tool_keywords.any? { |keyword| query_lower.include?(keyword) }
709
+ end
710
+
711
+ def plan_for_query(client, query, config)
712
+ response = client.chat_raw(
713
+ messages: [
714
+ { role: "system", content: planning_system_prompt },
715
+ { role: "user", content: query }
716
+ ],
717
+ allow_chat: true,
718
+ format: planning_schema,
719
+ options: { temperature: config.temperature }
720
+ )
721
+
722
+ plan = parse_tool_content(response.message&.content.to_s)
723
+
724
+ if !plan["needs_tools"] && query_requires_tools?(query)
725
+ plan["needs_tools"] = true
726
+ plan["reasoning"] = "Query contains tool-related keywords that require data retrieval."
727
+ end
728
+
729
+ plan
730
+ end
731
+
732
+ def print_plan(plan)
733
+ return unless show_plan?
734
+
735
+ puts "Plan:"
736
+ puts "- Needs tools: #{plan['needs_tools']}"
737
+ puts "- Reasoning: #{plan['reasoning']}"
738
+ return if plan["tool_requests"].empty?
739
+
740
+ puts "- Tool requests:"
741
+ plan["tool_requests"].each do |request|
742
+ args_hint = request["args_hint"]
743
+ suffix = args_hint && !args_hint.empty? ? " (#{args_hint})" : ""
744
+ puts " - #{request['name']}: #{request['purpose']}#{suffix}"
745
+ end
746
+ end
747
+
748
+ def chat_response(client, messages, config)
749
+ content = +""
750
+ print LLM_PROMPT
751
+
752
+ client.chat_raw(
753
+ messages: messages,
754
+ allow_chat: true,
755
+ options: { temperature: config.temperature },
756
+ stream: true
757
+ ) do |chunk|
758
+ token = chunk.dig("message", "content").to_s
759
+ next if token.empty?
760
+
761
+ content << token
762
+ print token
763
+ end
764
+
765
+ puts
766
+ content
767
+ end
768
+
769
+ class ConsoleStream
770
+ def initialize
771
+ @started = false
772
+ end
773
+
774
+ def emit(event, text: nil, **)
775
+ return unless event == :token && text
776
+
777
+ unless @started
778
+ print LLM_PROMPT
779
+ @started = true
780
+ end
781
+ print text
782
+ end
783
+
784
+ def finish
785
+ puts if @started
786
+ end
787
+ end
788
+
789
+ def run_console(client, config)
790
+ configure_dhanhq!
791
+ print_banner(config)
792
+ reader = build_reader
793
+ load_history(reader, HISTORY_PATH)
794
+ tools = build_tools
795
+ system_prompt = [tool_system_prompt, system_prompt_from_env].compact.join("\n\n")
796
+ chat_messages = []
797
+ system_prompt_from_env&.then { |prompt| chat_messages << { role: "system", content: prompt } }
798
+
799
+ loop do
800
+ input = read_input(reader)
801
+ break unless input
802
+
803
+ text = input.strip
804
+ next if text.empty?
805
+ break if exit_command?(text)
806
+
807
+ update_history(HISTORY_PATH, text)
808
+ plan = plan_for_query(client, text, config)
809
+ print_plan(plan)
810
+
811
+ unless plan["needs_tools"]
812
+ chat_messages << { role: "user", content: text }
813
+ content = chat_response(client, chat_messages, config)
814
+ chat_messages << { role: "assistant", content: content }
815
+ next
816
+ end
817
+
818
+ stream = show_llm_summary? ? ConsoleStream.new : nil
819
+ executor = Ollama::Agent::Executor.new(client, tools: tools, max_steps: 10, stream: stream)
820
+ result = executor.run(system: system_prompt, user: text)
821
+ stream&.finish
822
+
823
+ if tool_messages(executor.messages).empty?
824
+ if allow_no_tool_output?
825
+ puts "No tool results were produced."
826
+ print LLM_PROMPT
827
+ puts result
828
+ else
829
+ print_hallucination_warning
830
+ end
831
+ else
832
+ print_tool_results(executor.messages)
833
+ print LLM_PROMPT
834
+ puts result
835
+ end
836
+ end
837
+ rescue Interrupt
838
+ puts "\nExiting..."
839
+ end
840
+
841
+ config = build_config
842
+ client = Ollama::Client.new(config: config)
843
+ run_console(client, config)