DhanHQ 3.0.1 → 3.1.0

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +152 -4
  5. data/docs/CONSTANTS_REFERENCE.md +3 -2
  6. data/exe/dhanhq-mcp +7 -0
  7. data/lib/DhanHQ/agent/tool_registry.rb +51 -2
  8. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  9. data/lib/DhanHQ/constants.rb +3 -2
  10. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  11. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
  12. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  13. data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
  14. data/lib/DhanHQ/mcp/server.rb +172 -9
  15. data/lib/DhanHQ/models/instrument.rb +44 -14
  16. data/lib/DhanHQ/rate_limiter.rb +5 -3
  17. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  18. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  19. data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
  20. data/lib/DhanHQ/resources/orders.rb +2 -0
  21. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  22. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  23. data/lib/DhanHQ/resources/twap_orders.rb +1 -0
  24. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  25. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  26. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  27. data/lib/DhanHQ/risk/pipeline.rb +8 -1
  28. data/lib/DhanHQ/skills/base.rb +54 -3
  29. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  30. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  31. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
  32. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  33. data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
  34. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  35. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  36. data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
  37. data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
  38. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  39. data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
  40. data/lib/DhanHQ/version.rb +1 -1
  41. data/lib/dhan_hq.rb +47 -0
  42. data/skills/dhanhq-ruby/SKILL.md +174 -41
  43. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  44. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  45. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  46. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  47. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  48. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  49. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  50. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  51. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  52. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  53. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  54. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  55. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  56. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  57. data/skills/dhanhq-ruby/references/funds.md +67 -0
  58. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  59. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  60. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  61. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  62. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  63. data/skills/dhanhq-ruby/references/orders.md +200 -6
  64. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  65. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  66. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  67. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  68. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  69. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  70. metadata +39 -3
  71. data/skills/dhanhq-ruby/references/market_data.md +0 -3
@@ -1,7 +1,201 @@
1
- # Orders with agents
1
+ # Orders Complete Reference (Ruby SDK)
2
2
 
3
- 1. Search or otherwise verify the instrument.
4
- 2. Build order params using Dhan constants and API docs.
5
- 3. Run `DhanHQ::Agent::OrderPreview.new(params).to_h`.
6
- 4. Ask the user to confirm the preview.
7
- 5. Place only if `DHANHQ_MCP_ENABLE_WRITES=true`, `LIVE_TRADING=true`, and `orders:write` scope are present.
3
+ Critical API rules:
4
+ - Order placement, modification, cancellation, super orders, and forever orders require static IP whitelisting.
5
+ - Dhan's current order docs say API market orders are converted to limit orders with MPP.
6
+
7
+ ## Regular Orders
8
+
9
+ ### Place Order
10
+
11
+ In the Ruby SDK, prefer using the model class `DhanHQ::Models::Order.place(params)`:
12
+
13
+ ```ruby
14
+ order = DhanHQ::Models::Order.place(
15
+ security_id: "2885",
16
+ exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
17
+ transaction_type: DhanHQ::Constants::TransactionType::BUY,
18
+ quantity: 10,
19
+ order_type: DhanHQ::Constants::OrderType::LIMIT,
20
+ product_type: DhanHQ::Constants::ProductType::CNC,
21
+ price: 2450.0,
22
+ validity: DhanHQ::Constants::Validity::DAY,
23
+ correlation_id: "rebalance_001"
24
+ )
25
+
26
+ if order
27
+ puts "Placed Order ID: #{order.order_id}, Status: #{order.order_status}"
28
+ end
29
+ ```
30
+
31
+ Alternatively, you can use the ActiveRecord-style `new` and `save` flow:
32
+
33
+ ```ruby
34
+ order = DhanHQ::Models::Order.new(
35
+ security_id: "2885",
36
+ exchange_segment: "NSE_EQ",
37
+ transaction_type: "BUY",
38
+ quantity: 10,
39
+ order_type: "LIMIT",
40
+ product_type: "CNC",
41
+ price: 2450.0,
42
+ validity: "DAY"
43
+ )
44
+ order.save # Places the order via API
45
+ ```
46
+
47
+ ### Slice Order
48
+
49
+ If placing a large quantity that exceeds exchange freeze limits, the SDK handles slicing automatically when using the slice API:
50
+
51
+ ```ruby
52
+ order.slice_order(
53
+ slice_quantity: 1000
54
+ )
55
+ ```
56
+
57
+ ### Modify Order
58
+
59
+ Modify a pending order directly on the model instance:
60
+
61
+ ```ruby
62
+ order = DhanHQ::Models::Order.find("112111182198")
63
+ if order.pending?
64
+ order.modify(
65
+ price: 2455.0,
66
+ quantity: 10,
67
+ validity: "DAY"
68
+ )
69
+ end
70
+ ```
71
+
72
+ The modify request expects the full placed quantity, not the pending quantity.
73
+
74
+ ### Cancel Order
75
+
76
+ Cancel a pending order directly on the model instance:
77
+
78
+ ```ruby
79
+ order = DhanHQ::Models::Order.find("112111182198")
80
+ order.cancel # Returns true on success
81
+ ```
82
+
83
+ ### Order Retrieval
84
+
85
+ ```ruby
86
+ # Fetch all orders for today
87
+ orders = DhanHQ::Models::Order.all
88
+
89
+ # Find order by ID
90
+ order = DhanHQ::Models::Order.find("112111182198")
91
+
92
+ # Find order by correlation ID
93
+ order = DhanHQ::Models::Order.find_by_correlation("rebalance_001")
94
+
95
+ # Fetch today's trades
96
+ trades = DhanHQ::Models::Trade.today
97
+
98
+ # Find trades by order ID
99
+ trade = DhanHQ::Models::Trade.find_by_order_id("112111182198")
100
+
101
+ # Fetch trade history
102
+ history = DhanHQ::Models::Trade.history(
103
+ from_date: "2025-01-01",
104
+ to_date: "2025-01-31",
105
+ page: 0
106
+ )
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Super Orders (Bracket/Cover Orders)
112
+
113
+ ### Place Super Order
114
+
115
+ Use the `DhanHQ::Models::SuperOrder.create` method:
116
+
117
+ ```ruby
118
+ super_order = DhanHQ::Models::SuperOrder.create(
119
+ security_id: "2885",
120
+ exchange_segment: "NSE_EQ",
121
+ transaction_type: "BUY",
122
+ quantity: 1,
123
+ order_type: "LIMIT",
124
+ product_type: "INTRADAY",
125
+ price: 2450.0,
126
+ target_price: 2500.0,
127
+ stop_loss_price: 2420.0,
128
+ trailing_jump: 10.0
129
+ )
130
+
131
+ puts "Placed Super Order ID: #{super_order.order_id}"
132
+ ```
133
+
134
+ ### Modify Super Order
135
+
136
+ ```ruby
137
+ super_order.modify(
138
+ leg_name: "ENTRY_LEG",
139
+ price: 2455.0,
140
+ quantity: 1,
141
+ target_price: 2510.0,
142
+ stop_loss_price: 2425.0,
143
+ trailing_jump: 10.0
144
+ )
145
+ ```
146
+
147
+ - `ENTRY_LEG` can modify the whole structure while the entry order is `PENDING` or `PART_TRADED`.
148
+ - After entry is `TRADED`, only price and trailing jump of `TARGET_LEG` and `STOP_LOSS_LEG` can be modified.
149
+
150
+ ### Cancel Super Order
151
+
152
+ ```ruby
153
+ super_order.cancel("ENTRY_LEG") # Cancels all legs
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Forever Orders (GTT Orders)
159
+
160
+ ### Place Forever Order
161
+
162
+ Use the `DhanHQ::Models::ForeverOrder.create` method:
163
+
164
+ ```ruby
165
+ # Single Trigger
166
+ gtt_order = DhanHQ::Models::ForeverOrder.create(
167
+ security_id: "2885",
168
+ exchange_segment: "NSE_EQ",
169
+ transaction_type: "BUY",
170
+ product_type: "CNC",
171
+ order_type: "LIMIT",
172
+ quantity: 5,
173
+ price: 2300.0,
174
+ trigger_price: 2305.0,
175
+ order_flag: "SINGLE",
176
+ validity: "DAY"
177
+ )
178
+
179
+ # OCO (One Cancels Other) target + stop loss
180
+ oco_order = DhanHQ::Models::ForeverOrder.create(
181
+ security_id: "2885",
182
+ exchange_segment: "NSE_EQ",
183
+ transaction_type: "SELL",
184
+ product_type: "CNC",
185
+ order_type: "LIMIT",
186
+ quantity: 5,
187
+ price: 2700.00, # Target price (price of first leg)
188
+ trigger_price: 2695.00, # Target trigger price (trigger of first leg)
189
+ price1: 2200.00, # Stop loss price (price of second leg)
190
+ trigger_price1: 2205.00, # Stop loss trigger price (trigger of second leg)
191
+ quantity1: 5, # Stop loss quantity (quantity of second leg)
192
+ order_flag: "OCO",
193
+ validity: "DAY"
194
+ )
195
+ ```
196
+
197
+ ### Cancel Forever Order
198
+
199
+ ```ruby
200
+ gtt_order.cancel # Returns true on success
201
+ ```
@@ -0,0 +1,93 @@
1
+ # Portfolio And Positions — Complete Reference (Ruby SDK)
2
+
3
+ ## Holdings
4
+
5
+ Use `DhanHQ::Models::Holding.all`:
6
+
7
+ ```ruby
8
+ holdings = DhanHQ::Models::Holding.all
9
+
10
+ holdings.each do |holding|
11
+ puts "#{holding.trading_symbol} available=#{holding.available_qty}"
12
+ end
13
+ ```
14
+
15
+ Useful holding fields:
16
+ - `exchange`
17
+ - `trading_symbol`
18
+ - `security_id`
19
+ - `isin`
20
+ - `total_qty`
21
+ - `dp_qty`
22
+ - `t1_qty`
23
+ - `available_qty`
24
+ - `collateral_qty`
25
+ - `avg_cost_price`
26
+
27
+ ---
28
+
29
+ ## Positions
30
+
31
+ Use `DhanHQ::Models::Position.all`:
32
+
33
+ ```ruby
34
+ positions = DhanHQ::Models::Position.all
35
+ open_positions = positions.select { |p| p.net_qty.to_i != 0 }
36
+ ```
37
+
38
+ Useful position fields:
39
+ - `trading_symbol`
40
+ - `security_id`
41
+ - `position_type` # "LONG" or "SHORT"
42
+ - `exchange_segment`
43
+ - `product_type`
44
+ - `buy_avg`
45
+ - `buy_qty`
46
+ - `sell_avg`
47
+ - `sell_qty`
48
+ - `net_qty`
49
+ - `realized_profit`
50
+ - `unrealized_profit`
51
+
52
+ ---
53
+
54
+ ## Convert Position
55
+
56
+ Convert an open position (e.g. from Intraday to CNC/Carry Forward):
57
+
58
+ ```ruby
59
+ # In the Ruby SDK, call convert directly on a Position model instance
60
+ position = DhanHQ::Models::Position.all.first
61
+ position.convert(
62
+ from_product_type: "INTRADAY",
63
+ to_product_type: "CNC",
64
+ position_type: "LONG",
65
+ convert_qty: 1
66
+ )
67
+ ```
68
+
69
+ ---
70
+
71
+ ## eDIS Authorization
72
+
73
+ For selling delivery holdings, authorization is handled via `DhanHQ::Models::EDIS`:
74
+
75
+ ### Step 1: Generate TPIN
76
+ ```ruby
77
+ DhanHQ::Models::EDIS.generate_tpin
78
+ ```
79
+
80
+ ### Step 2: Open Browser Authorization
81
+ ```ruby
82
+ DhanHQ::Models::EDIS.open_browser_for_tpin(
83
+ isin: "INE002A01018",
84
+ qty: 5,
85
+ exchange: "NSE"
86
+ )
87
+ ```
88
+
89
+ ### Step 3: Inquiry eDIS Approval
90
+ ```ruby
91
+ inquiry = DhanHQ::Models::EDIS.inquiry(isin: "INE002A01018")
92
+ puts "Approved Qty: #{inquiry.aprvd_qty}, Status: #{inquiry.status}"
93
+ ```
@@ -0,0 +1,62 @@
1
+ # ScanX — Fundamental and Technical Data
2
+
3
+ Use ScanX when Dhan APIs do not cover the needed data. Dhan provides execution, quotes, OHLC, option chain, and portfolio. ScanX provides fundamentals, technical indicators, shareholding, and screeners.
4
+
5
+ ## Capability Gap
6
+
7
+ | Data needed | Use |
8
+ |------------|-----|
9
+ | PE ratio, EPS, Book Value, PB Ratio | ScanX |
10
+ | Revenue, Net Profit, EBITDA | ScanX |
11
+ | Debt-to-equity, Return on Equity | ScanX |
12
+ | RSI(14), MACD(12,26), ADX(14), ATR(14) | ScanX |
13
+ | Promoter %, FII %, DII %, Public % | ScanX |
14
+ | Quarterly results history (2015–present) | ScanX |
15
+ | Balance Sheet, Cash Flows | ScanX |
16
+ | Stock screeners (fundamental/technical) | ScanX |
17
+ | Live quotes, OHLC, option chain | Dhan |
18
+ | Order execution, portfolio | Dhan |
19
+
20
+ ---
21
+
22
+ ## Company Page URL Pattern
23
+
24
+ `https://scanx.trade/company/{slug}`
25
+
26
+ Slug rules:
27
+ - Lowercase the full registered company name.
28
+ - Replace spaces with hyphens.
29
+ - Include "ltd" if part of the official name.
30
+
31
+ ---
32
+
33
+ ## Combined Workflow: Analyze on ScanX → Execute on Dhan
34
+
35
+ ```ruby
36
+ # Step 1: fetch ScanX page for fundamentals/technicals
37
+ # -> https://scanx.trade/company/reliance-industries-ltd
38
+ # -> Extract metrics: PE, RSI, etc.
39
+
40
+ # Step 2: resolve security_id from Dhan security master
41
+ require_relative "../scripts/dhan_helpers"
42
+ get_client
43
+
44
+ row = resolve_symbol("RELIANCE", "NSE_EQ")
45
+ security_id = row["security_id"]
46
+
47
+ # Step 3: Get live quotes from Dhan
48
+ quote_resp = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [security_id.to_i])
49
+ ltp = quote_resp[:data]["NSE_EQ"][security_id.to_s][:last_price].to_f
50
+
51
+ # Step 4: Place order via Dhan
52
+ order = DhanHQ::Models::Order.place(
53
+ security_id: security_id,
54
+ exchange_segment: "NSE_EQ",
55
+ transaction_type: "BUY",
56
+ quantity: 1,
57
+ order_type: "LIMIT",
58
+ product_type: "CNC",
59
+ price: ltp,
60
+ validity: "DAY"
61
+ )
62
+ ```
@@ -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