DhanHQ 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +23 -0
  3. data/CHANGELOG.md +26 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  6. data/lib/DhanHQ/client.rb +53 -34
  7. data/lib/DhanHQ/models/alert_order.rb +1 -1
  8. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  9. data/lib/DhanHQ/version.rb +1 -1
  10. data/lib/dhan_hq.rb +4 -0
  11. data/skills/dhanhq-ruby/SKILL.md +208 -0
  12. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  13. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  14. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  15. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  16. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  17. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  18. data/skills/dhanhq-ruby/examples/order_management.rb +112 -0
  19. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  20. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  21. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  22. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  23. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  24. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  25. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  26. data/skills/dhanhq-ruby/references/funds.md +67 -0
  27. data/skills/dhanhq-ruby/references/instruments.md +91 -0
  28. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  29. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  30. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  31. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  32. data/skills/dhanhq-ruby/references/orders.md +203 -0
  33. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  34. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  35. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  36. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  37. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  38. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  39. metadata +32 -2
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dhan_hq"
4
+ require "json"
5
+ require "csv"
6
+
7
+ # Resolution helper for credentials and loading config files.
8
+ def _load_config(path)
9
+ JSON.parse(File.read(path))
10
+ rescue StandardError
11
+ {}
12
+ end
13
+
14
+ # Initialize the DhanHQ configuration.
15
+ def get_client(config_path = nil)
16
+ client_id = nil
17
+ access_token = nil
18
+
19
+ paths_to_try = [
20
+ config_path,
21
+ ENV.fetch("DHAN_CONFIG_PATH", nil),
22
+ "config.json"
23
+ ]
24
+
25
+ paths_to_try.each do |path|
26
+ next if path.nil? || path.to_s.empty? || !File.exist?(path)
27
+
28
+ config = _load_config(path)
29
+ client_id = config["client_id"] || config[:client_id] || client_id
30
+ access_token = config["access_token"] || config[:access_token] || access_token
31
+ break if client_id && access_token
32
+ end
33
+
34
+ client_id ||= ENV.fetch("DHAN_CLIENT_ID", nil)
35
+ access_token ||= ENV.fetch("DHAN_ACCESS_TOKEN", nil)
36
+
37
+ if client_id.nil? || client_id.to_s.empty? || access_token.nil? || access_token.to_s.empty?
38
+ raise ArgumentError, "Credentials not found. Set DHAN_CLIENT_ID and DHAN_ACCESS_TOKEN, or use config.json"
39
+ end
40
+
41
+ DhanHQ.configure do |config|
42
+ config.client_id = client_id.to_s
43
+ config.access_token = access_token.to_s
44
+ end
45
+
46
+ DhanHQ.ensure_configuration!
47
+ end
48
+
49
+ # Extract data field or raise error if response failed.
50
+ def unwrap_sdk_data(response)
51
+ if response.respond_to?(:key?)
52
+ status = response[:status] || response["status"]
53
+ remarks = response[:remarks] || response["remarks"]
54
+ data = response[:data] || response["data"]
55
+
56
+ raise remarks.to_s unless status == "success"
57
+
58
+ return data
59
+ end
60
+ response
61
+ end
62
+
63
+ # Resolve a symbol to its security details.
64
+ def resolve_symbol(symbol, exchange_segment = DhanHQ::Constants::ExchangeSegment::NSE_EQ, _instrument_name = DhanHQ::Constants::InstrumentType::EQUITY)
65
+ inst = DhanHQ::Models::Instrument.find(exchange_segment, symbol, exact_match: true)
66
+ inst ||= DhanHQ::Models::Instrument.find(exchange_segment, symbol, exact_match: false)
67
+ return nil unless inst
68
+
69
+ {
70
+ "security_id" => inst.security_id.to_s,
71
+ "trading_symbol" => inst.symbol_name,
72
+ "display_name" => inst.display_name,
73
+ "exchange_segment" => inst.exchange_segment,
74
+ "instrument_name" => inst.instrument
75
+ }
76
+ end
77
+
78
+ # Resolve derivative details like Strike/Expiry/OptionType from the security list.
79
+ def resolve_derivative(underlying, strike: nil, option_type: nil, expiry: nil, exchange: "NSE")
80
+ exchange_segment = case exchange.to_s.upcase
81
+ when "NSE" then DhanHQ::Constants::ExchangeSegment::NSE_FNO
82
+ when "BSE" then DhanHQ::Constants::ExchangeSegment::BSE_FNO
83
+ when "MCX" then DhanHQ::Constants::ExchangeSegment::MCX_COMM
84
+ else DhanHQ::Constants::ExchangeSegment::NSE_FNO
85
+ end
86
+
87
+ instruments = DhanHQ::Models::Instrument.by_segment(exchange_segment)
88
+ return nil if instruments.empty?
89
+
90
+ matches = instruments.select do |inst|
91
+ symbol_match = inst.underlying_symbol&.upcase == underlying.upcase ||
92
+ inst.symbol_name&.upcase&.start_with?(underlying.upcase)
93
+
94
+ match = symbol_match
95
+
96
+ match &&= inst.strike_price&.to_f == strike.to_f if strike
97
+
98
+ if option_type
99
+ match &&= if option_type.to_s.upcase == "FUT"
100
+ inst.instrument&.upcase&.include?("FUT")
101
+ else
102
+ inst.option_type&.upcase == option_type.to_s.upcase
103
+ end
104
+ end
105
+
106
+ match &&= inst.expiry_date == expiry if expiry
107
+
108
+ match
109
+ end
110
+
111
+ matches = matches.sort_by { |inst| [inst.expiry_date.to_s, inst.symbol_name.to_s] }
112
+ return nil if matches.empty?
113
+
114
+ row = matches.first
115
+ {
116
+ "security_id" => row.security_id.to_s,
117
+ "trading_symbol" => row.symbol_name,
118
+ "lot_size" => row.lot_size&.to_i || 1,
119
+ "tick_size" => row.tick_size&.to_f || 0.05,
120
+ "expiry" => row.expiry_date,
121
+ "instrument_name" => row.instrument
122
+ }
123
+ end
124
+
125
+ # Fetch lot size of an instrument from common fallback table or security master.
126
+ def get_lot_size(security_id: nil, trading_symbol: nil, underlying: nil)
127
+ common_lots = { "NIFTY" => 75, "BANKNIFTY" => 15, "FINNIFTY" => 25, "MIDCPNIFTY" => 50, "SENSEX" => 10 }
128
+
129
+ if underlying
130
+ name = underlying.upcase.gsub(/\s+/, "")
131
+ return common_lots[name] if common_lots.key?(name)
132
+ end
133
+
134
+ if trading_symbol
135
+ name = trading_symbol.upcase
136
+ common_lots.each { |k, v| return v if name.include?(k) }
137
+ end
138
+
139
+ # Lookup instrument via segment
140
+ if security_id
141
+ %w[NSE_EQ NSE_FNO IDX_I].each do |seg|
142
+ insts = begin
143
+ DhanHQ::Models::Instrument.by_segment(seg)
144
+ rescue StandardError
145
+ []
146
+ end
147
+ inst = insts.find { |i| i.security_id.to_s == security_id.to_s }
148
+ return inst.lot_size.to_i if inst&.lot_size
149
+ end
150
+ end
151
+
152
+ if trading_symbol
153
+ inst = DhanHQ::Models::Instrument.find_anywhere(trading_symbol)
154
+ return inst.lot_size.to_i if inst&.lot_size
155
+ end
156
+
157
+ nil
158
+ end
159
+
160
+ # Build a human-readable order preview.
161
+ def preview_order(security_id:, exchange_segment:, transaction_type:, quantity:, order_type:, product_type:, price: 0.0, trading_symbol: nil)
162
+ notional = price.to_f * quantity.to_i
163
+ lines = [
164
+ "--- ORDER PREVIEW ---",
165
+ "Security: #{trading_symbol || security_id}",
166
+ "Exchange: #{exchange_segment}",
167
+ "Action: #{transaction_type}",
168
+ "Quantity: #{quantity}",
169
+ "Order Type: #{order_type}",
170
+ "Product Type: #{product_type}",
171
+ "Price: #{order_type.to_s.upcase == DhanHQ::Constants::OrderType::MARKET ? "MARKET / MPP" : "Rs. #{"%.2f" % price}"}"
172
+ ]
173
+ if notional.positive? && order_type.to_s.upcase != DhanHQ::Constants::OrderType::MARKET
174
+ lines << "Notional: Rs. #{"%.2f" % notional}"
175
+ lines << "Warning: Notional exceeds Rs. 50,000" if notional > 50_000
176
+ end
177
+ lines << "---------------------"
178
+ lines.join("\n")
179
+ end
180
+
181
+ # Normalize option-chain data format.
182
+ def normalize_option_chain(response)
183
+ data = response[:data] || response["data"] || response
184
+
185
+ if data.key?(:strikes) || data.key?("strikes")
186
+ spot = data[:last_price] || data["last_price"]
187
+ strikes = data[:strikes] || data["strikes"]
188
+
189
+ rows = strikes.map do |s|
190
+ strike = s[:strike] || s["strike"]
191
+ ce = s[:call] || s["call"] || {}
192
+ pe = s[:put] || s["put"] || {}
193
+
194
+ row = { "strike" => strike.to_f }
195
+
196
+ # ce fields
197
+ row["ce_security_id"] = ce["security_id"] || ce[:security_id]
198
+ row["ce_ltp"] = ce["last_price"] || ce[:last_price]
199
+ row["ce_avg_price"] = ce["average_price"] || ce[:average_price]
200
+ row["ce_oi"] = ce["oi"] || ce[:oi]
201
+ row["ce_oi_change"] = ce["oi_change"] || ce[:oi_change]
202
+ row["ce_volume"] = ce["volume"] || ce[:volume]
203
+ row["ce_iv"] = ce["implied_volatility"] || ce[:implied_volatility]
204
+ row["ce_bid_price"] = ce["top_bid_price"] || ce[:top_bid_price]
205
+ row["ce_bid_qty"] = ce["top_bid_quantity"] || ce[:top_bid_quantity]
206
+ row["ce_ask_price"] = ce["top_ask_price"] || ce[:top_ask_price]
207
+ row["ce_ask_qty"] = ce["top_ask_quantity"] || ce[:top_ask_quantity]
208
+
209
+ ce_greeks = ce["greeks"] || ce[:greeks] || {}
210
+ row["ce_delta"] = ce_greeks["delta"] || ce_greeks[:delta]
211
+ row["ce_gamma"] = ce_greeks["gamma"] || ce_greeks[:gamma]
212
+ row["ce_theta"] = ce_greeks["theta"] || ce_greeks[:theta]
213
+ row["ce_vega"] = ce_greeks["vega"] || ce_greeks[:vega]
214
+
215
+ # pe fields
216
+ row["pe_security_id"] = pe["security_id"] || pe[:security_id]
217
+ row["pe_ltp"] = pe["last_price"] || pe[:last_price]
218
+ row["pe_avg_price"] = pe["average_price"] || pe[:average_price]
219
+ row["pe_oi"] = pe["oi"] || pe[:oi]
220
+ row["pe_oi_change"] = pe["oi_change"] || pe[:oi_change]
221
+ row["pe_volume"] = pe["volume"] || pe[:volume]
222
+ row["pe_iv"] = pe["implied_volatility"] || pe[:implied_volatility]
223
+ row["pe_bid_price"] = pe["top_bid_price"] || pe[:top_bid_price]
224
+ row["pe_bid_qty"] = pe["top_bid_quantity"] || pe[:top_bid_quantity]
225
+ row["pe_ask_price"] = pe["top_ask_price"] || pe[:top_ask_price]
226
+ row["pe_ask_qty"] = pe["top_ask_quantity"] || pe[:top_ask_quantity]
227
+
228
+ pe_greeks = pe["greeks"] || pe[:greeks] || {}
229
+ row["pe_delta"] = pe_greeks["delta"] || pe_greeks[:delta]
230
+ row["pe_gamma"] = pe_greeks["gamma"] || pe_greeks[:gamma]
231
+ row["pe_theta"] = pe_greeks["theta"] || pe_greeks[:theta]
232
+ row["pe_vega"] = pe_greeks["vega"] || pe_greeks[:vega]
233
+
234
+ row
235
+ end
236
+
237
+ return spot.to_f, rows
238
+ end
239
+
240
+ [0.0, []]
241
+ end
242
+
243
+ # Fetch option-chain and return the rows.
244
+ def fetch_chain_df(_dhan_client = nil, under_security_id:, expiry:, under_exchange_segment: DhanHQ::Constants::ExchangeSegment::IDX_I)
245
+ chain = DhanHQ::Models::OptionChain.fetch(
246
+ underlying_scrip: under_security_id.to_i,
247
+ underlying_seg: under_exchange_segment,
248
+ expiry: expiry
249
+ )
250
+ normalize_option_chain(chain)
251
+ end
252
+
253
+ # Find ATM row nearest to spot.
254
+ def find_atm_row(chain_rows, spot)
255
+ chain_rows.min_by { |row| (row["strike"].to_f - spot.to_f).abs }
256
+ end
257
+
258
+ # Aggregate holdings and positions into summary.
259
+ def format_pnl_report(holdings = nil, positions = nil)
260
+ holdings_list = holdings || begin
261
+ DhanHQ::Models::Holding.all
262
+ rescue StandardError
263
+ []
264
+ end
265
+ positions_list = positions || begin
266
+ DhanHQ::Models::Position.all
267
+ rescue StandardError
268
+ []
269
+ end
270
+
271
+ report = {
272
+ "total_investment" => 0.0,
273
+ "current_value" => 0.0,
274
+ "total_pnl" => 0.0,
275
+ "day_pnl" => 0.0,
276
+ "holdings_count" => holdings_list.size,
277
+ "positions_count" => positions_list.size
278
+ }
279
+
280
+ holdings_list.each do |holding|
281
+ qty = holding.respond_to?(:total_qty) ? holding.total_qty : (holding[:totalQty] || holding["totalQty"] || 0)
282
+ cost = holding.respond_to?(:avg_cost_price) ? holding.avg_cost_price : (holding[:avgCostPrice] || holding["avgCostPrice"] || 0)
283
+ val = holding.respond_to?(:market_value) ? holding.market_value : (holding[:marketValue] || holding["marketValue"] || 0)
284
+ pnl = holding.respond_to?(:pnl) ? holding.pnl : (holding[:pnl] || holding["pnl"] || 0)
285
+ day_pnl = holding.respond_to?(:day_pnl) ? holding.day_pnl : (holding[:dayPnl] || holding["dayPnl"] || 0)
286
+
287
+ report["total_investment"] += cost.to_f * qty.to_f
288
+ report["current_value"] += val.to_f
289
+ report["total_pnl"] += pnl.to_f
290
+ report["day_pnl"] += day_pnl.to_f
291
+ end
292
+
293
+ positions_list.each do |position|
294
+ realized = position.respond_to?(:realized_profit) ? position.realized_profit : (position[:realizedProfit] || position["realizedProfit"] || 0)
295
+ unrealized = position.respond_to?(:unrealized_profit) ? position.unrealized_profit : (position[:unrealizedProfit] || position["unrealizedProfit"] || 0)
296
+ report["total_pnl"] += realized.to_f + unrealized.to_f
297
+ end
298
+
299
+ report
300
+ end
301
+
302
+ # Run pre-flight margin checks against Dhan API.
303
+ def check_margin(_dhan_client = nil, security_id:, exchange_segment:, transaction_type:, quantity:, product_type:, price:, trigger_price: 0.0)
304
+ margin = DhanHQ::Models::Margin.calculate(
305
+ security_id: security_id.to_s,
306
+ exchange_segment: exchange_segment,
307
+ transaction_type: transaction_type,
308
+ quantity: quantity,
309
+ product_type: product_type,
310
+ price: price,
311
+ trigger_price: trigger_price
312
+ )
313
+ DhanHQ::Models::Funds.fetch
314
+
315
+ {
316
+ "total_margin" => margin.total_margin.to_f,
317
+ "available_balance" => margin.available_balance.to_f,
318
+ "brokerage" => margin.brokerage.to_f,
319
+ "leverage" => margin.leverage.to_f,
320
+ "sufficient" => margin.available_balance.to_f >= margin.total_margin.to_f,
321
+ "shortfall" => [0.0, margin.total_margin.to_f - margin.available_balance.to_f].max
322
+ }
323
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:disable Style/FormatStringToken
3
+ # rubocop:disable Performance/CollectionLiteralInLoop
4
+
5
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
6
+ require "dhan_hq"
7
+ require_relative "dhan_helpers"
8
+
9
+ def load_security_master(_segment = "compact")
10
+ puts "Loading security master segments..."
11
+ # Unlike Python, Ruby SDK by_segment is segment-specific.
12
+ # We will resolve dynamically when searching.
13
+ end
14
+
15
+ def search_equity(query, limit = 10)
16
+ query_upper = query.to_s.upcase.strip
17
+
18
+ results = []
19
+ %w[NSE_EQ BSE_EQ].each do |segment|
20
+ instruments = begin
21
+ DhanHQ::Models::Instrument.by_segment(segment)
22
+ rescue StandardError
23
+ []
24
+ end
25
+ instruments.each do |inst|
26
+ next unless inst.instrument == DhanHQ::Constants::InstrumentType::EQUITY
27
+
28
+ symbol_name = inst.symbol_name.to_s.upcase
29
+ underlying = inst.underlying_symbol.to_s.upcase
30
+ display_name = inst.display_name.to_s.upcase
31
+
32
+ if symbol_name == query_upper || display_name == query_upper
33
+ results << inst
34
+ elsif symbol_name.include?(query_upper) || display_name.include?(query_upper) || underlying.include?(query_upper)
35
+ results << inst
36
+ end
37
+
38
+ break if results.size >= limit
39
+ end
40
+ break if results.size >= limit
41
+ end
42
+ begin
43
+ results.head(limit)
44
+ rescue StandardError
45
+ results.first(limit)
46
+ end
47
+ end
48
+
49
+ def search_derivative(underlying, strike: nil, option_type: nil, expiry: nil, limit: 20)
50
+ # Look in NSE_FNO and BSE_FNO
51
+ results = []
52
+ %w[NSE_FNO BSE_FNO].each do |segment|
53
+ instruments = begin
54
+ DhanHQ::Models::Instrument.by_segment(segment)
55
+ rescue StandardError
56
+ []
57
+ end
58
+ matched = instruments.select do |inst|
59
+ symbol_match = inst.underlying_symbol&.upcase == underlying.upcase ||
60
+ inst.symbol_name&.upcase&.start_with?(underlying.upcase)
61
+
62
+ match = symbol_match
63
+
64
+ if option_type == "FUT"
65
+ match &&= inst.instrument&.upcase&.include?("FUT")
66
+ elsif %w[CE PE].include?(option_type)
67
+ match &&= inst.option_type&.upcase == option_type
68
+ end
69
+
70
+ match &&= inst.strike_price&.to_f == strike.to_f if strike
71
+
72
+ match &&= inst.expiry_date == expiry if expiry
73
+
74
+ match
75
+ end
76
+
77
+ results.concat(matched)
78
+ end
79
+
80
+ results.sort_by { |inst| [inst.expiry_date.to_s, inst.symbol_name.to_s] }.first(limit)
81
+ end
82
+
83
+ def parse_query(query)
84
+ parts = query.upcase.split
85
+
86
+ return { type: :equity, name: query } if parts.size <= 2 && parts.none? { |p| %w[CE PE FUT FUTURE].include?(p) }
87
+
88
+ underlying = parts[0]
89
+ strike = nil
90
+ option_type = nil
91
+ expiry = nil
92
+
93
+ parts[1..].each do |part|
94
+ if %w[CE PE].include?(part)
95
+ option_type = part
96
+ elsif %w[FUT FUTURE].include?(part)
97
+ option_type = "FUT"
98
+ elsif part.include?("-") && part.length == 10
99
+ expiry = part
100
+ else
101
+ begin
102
+ strike = Float(part)
103
+ rescue ArgumentError
104
+ underlying += " #{part}"
105
+ end
106
+ end
107
+ end
108
+
109
+ {
110
+ type: :fno,
111
+ underlying: underlying,
112
+ strike: strike,
113
+ option_type: option_type,
114
+ expiry: expiry
115
+ }
116
+ end
117
+
118
+ def main
119
+ if ARGV.empty?
120
+ puts "Usage: ruby scripts/resolve_security.rb <query>"
121
+ puts "Examples: \"RELIANCE\", \"HDFC Bank\", \"NIFTY 24000 CE 2025-03-27\""
122
+ exit 1
123
+ end
124
+
125
+ query = ARGV.join(" ")
126
+ parsed = parse_query(query)
127
+
128
+ # Ensure configuration is initialized (can be dummy if just reading files)
129
+ begin
130
+ DhanHQ.configure_with_env
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ results = if parsed[:type] == :equity
136
+ search_equity(parsed[:name])
137
+ else
138
+ search_derivative(
139
+ parsed[:underlying],
140
+ strike: parsed[:strike],
141
+ option_type: parsed[:option_type],
142
+ expiry: parsed[:expiry]
143
+ )
144
+ end
145
+
146
+ if results.empty?
147
+ puts "No instruments found for: #{query}"
148
+ return
149
+ end
150
+
151
+ puts "\nResults for: #{query}\n\n"
152
+ printf("%-15s | %-18s | %-12s | %-12s | %-6s | %-10s\n", "Security ID", "Trading Symbol", "Exchange Seg", "Instrument", "Lot", "Expiry")
153
+ puts "-" * 85
154
+ results.each do |row|
155
+ printf(
156
+ "%-15s | %-18s | %-12s | %-12s | %-6d | %-10s\n",
157
+ row.security_id.to_s,
158
+ row.symbol_name.to_s,
159
+ row.exchange_segment.to_s,
160
+ row.instrument.to_s,
161
+ row.lot_size.to_i,
162
+ row.expiry_date.to_s
163
+ )
164
+ end
165
+ puts
166
+ end
167
+
168
+ main if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:disable Style/FormatStringToken
3
+ # rubocop:disable Naming/AccessorMethodName
4
+
5
+ require "json"
6
+ require "fileutils"
7
+ require "date"
8
+
9
+ def _get_log_path
10
+ plugin_data = ENV.fetch("CLAUDE_PLUGIN_DATA", nil)
11
+ if plugin_data && !plugin_data.empty?
12
+ FileUtils.mkdir_p(plugin_data)
13
+ File.join(plugin_data, "trades.jsonl")
14
+ else
15
+ default_dir = File.expand_path("../data", __dir__)
16
+ FileUtils.mkdir_p(default_dir)
17
+ File.join(default_dir, "trades.jsonl")
18
+ end
19
+ end
20
+
21
+ def log_order(order_params, response, notes = "")
22
+ log_path = _get_log_path
23
+ FileUtils.mkdir_p(File.dirname(log_path))
24
+
25
+ status = response[:status] || response["status"]
26
+ data = response[:data] || response["data"]
27
+ order_id = data.is_a?(Hash) ? (data[:orderId] || data["orderId"]) : nil
28
+
29
+ record = {
30
+ "timestamp" => Time.now.strftime("%Y-%m-%dT%H:%M:%S%:z"),
31
+ "order_params" => order_params,
32
+ "response" => response,
33
+ "order_id" => order_id,
34
+ "status" => status,
35
+ "notes" => notes
36
+ }
37
+
38
+ File.open(log_path, "a") do |f|
39
+ f.write("#{JSON.dump(record)}\n")
40
+ end
41
+
42
+ record
43
+ end
44
+
45
+ def _read_all_records
46
+ log_path = _get_log_path
47
+ return [] unless File.exist?(log_path)
48
+
49
+ records = []
50
+ File.foreach(log_path) do |line|
51
+ line_trimmed = line.strip
52
+ next if line_trimmed.empty?
53
+
54
+ records << JSON.parse(line_trimmed)
55
+ rescue JSON::ParserError
56
+ # skip invalid lines
57
+ end
58
+ records
59
+ end
60
+
61
+ def get_today_orders
62
+ today_str = begin
63
+ Date.today.isoformat
64
+ rescue StandardError
65
+ Date.today.strftime("%Y-%m-%d")
66
+ end
67
+ _read_all_records.select { |r| r["timestamp"].start_with?(today_str) }
68
+ end
69
+
70
+ def get_trade_history(days = 7)
71
+ cutoff_time = Time.now - (days * 24 * 60 * 60)
72
+ cutoff_str = cutoff_time.strftime("%Y-%m-%dT%H:%M:%S%:z")
73
+
74
+ records = _read_all_records.select { |r| r["timestamp"] >= cutoff_str }
75
+ records.sort_by { |r| r["timestamp"] }.reverse
76
+ end
77
+
78
+ def get_trade_summary(days = 7)
79
+ records = get_trade_history(days)
80
+ summary = {
81
+ "period_days" => days,
82
+ "total_orders" => records.size,
83
+ "successful" => records.count { |r| r["status"] == "success" },
84
+ "failed" => records.count { |r| r["status"] != "success" },
85
+ "buy_count" => 0,
86
+ "sell_count" => 0,
87
+ "instruments_traded" => []
88
+ }
89
+
90
+ instruments = []
91
+ records.each do |r|
92
+ params = r["order_params"] || {}
93
+ txn = params["transaction_type"] || params[:transaction_type] || ""
94
+ if txn.to_s.upcase == DhanHQ::Constants::TransactionType::BUY
95
+ summary["buy_count"] += 1
96
+ elsif txn.to_s.upcase == DhanHQ::Constants::TransactionType::SELL
97
+ summary["sell_count"] += 1
98
+ end
99
+
100
+ sid = params["security_id"] || params[:security_id] || params["trading_symbol"] || params[:trading_symbol]
101
+ instruments << sid.to_s if sid
102
+ end
103
+
104
+ summary["instruments_traded"] = instruments.uniq
105
+ summary
106
+ end
107
+
108
+ def print_today_orders
109
+ orders = get_today_orders
110
+ if orders.empty?
111
+ puts "No orders placed today."
112
+ return
113
+ end
114
+
115
+ puts "--- Today's Orders (#{orders.size} total) ---"
116
+ orders.each do |r|
117
+ params = r["order_params"] || {}
118
+ status = r["status"] || "unknown"
119
+ oid = r["order_id"] || "N/A"
120
+ txn = params["transaction_type"] || params[:transaction_type] || "?"
121
+ sym = params["trading_symbol"] || params[:trading_symbol] || params["security_id"] || params[:security_id] || "?"
122
+ qty = params["quantity"] || params[:quantity] || "?"
123
+ price = params["price"] || params[:price] || "MKT"
124
+
125
+ time_part = r["timestamp"].split("T")[1] ? r["timestamp"].split("T")[1][0...8] : "00:00:00"
126
+
127
+ printf(" [%s] %-8s | %-4s %dx %s @ %s | ID: %s\n", time_part, status.to_s.upcase, txn.to_s.upcase, qty, sym, price, oid)
128
+ end
129
+ end
130
+
131
+ print_today_orders if __FILE__ == $PROGRAM_NAME