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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a bull put spread (sell OTM put, buy further OTM put).
7
+ #
8
+ # Steps: find instrument → spot price → option chain →
9
+ # select strikes → build intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("bull_put_spread",
13
+ # symbol: "NIFTY",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 50
16
+ # )
17
+ #
18
+ class BullPutSpread < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a bull put spread: sell an OTM put, buy a further OTM put for defined risk."
22
+
23
+ param :symbol, type: :string, required: true
24
+ param :expiry, type: :string, required: true
25
+ param :quantity, type: :integer, default: 50
26
+ param :spread_width, type: :number, default: 200
27
+ param :max_loss, type: :number, default: 5000
28
+
29
+ step :find_instrument, priority: 1
30
+ step :get_spot_price, priority: 2
31
+ step :get_option_chain, priority: 3
32
+ step :select_strikes, priority: 4
33
+ step :build_intent, priority: 5
34
+
35
+ def find_instrument(ctx)
36
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
37
+ ctx
38
+ end
39
+
40
+ def get_spot_price(ctx)
41
+ ctx[:spot_price] = ctx[:instrument].ltp
42
+ ctx
43
+ end
44
+
45
+ def get_option_chain(ctx)
46
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
47
+ ctx
48
+ end
49
+
50
+ def select_strikes(ctx)
51
+ spot = ctx[:spot_price].to_f
52
+ chain = ctx[:chain]
53
+ spread = ctx[:spread_width].to_f
54
+
55
+ atm_strike_price = nearest_strike(chain, spot)[:strike].to_f
56
+
57
+ short_put = find_strike(chain, atm_strike_price - spread)
58
+ long_put = find_strike(chain, atm_strike_price - (spread * 2))
59
+
60
+ raise ArgumentError, "Could not build bull put spread — insufficient strikes in chain" unless short_put && long_put
61
+
62
+ ctx[:legs] = [
63
+ { action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike: short_put[:strike],
64
+ security_id: leg_security_id(short_put, "PE") },
65
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: long_put[:strike],
66
+ security_id: leg_security_id(long_put, "PE") }
67
+ ]
68
+ ctx
69
+ end
70
+
71
+ def build_intent(ctx)
72
+ ctx[:intent] = {
73
+ trade_type: "BULL_PUT_SPREAD",
74
+ symbol: ctx[:symbol],
75
+ expiry: ctx[:expiry],
76
+ quantity: ctx[:quantity],
77
+ spread_width: ctx[:spread_width],
78
+ max_loss: ctx[:max_loss],
79
+ legs: ctx[:legs],
80
+ note: "Bull put spread prepared. Await human confirmation before execution."
81
+ }
82
+ ctx
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -17,6 +17,10 @@ module DhanHQ
17
17
  # puts result[:intent]
18
18
  #
19
19
  class BuyAtmCall < Base
20
+ risk "trade_adjacent_read"
21
+ scope "orders:read"
22
+ description "Buy an at-the-money call option on an index (e.g. NIFTY)."
23
+
20
24
  param :symbol, type: :string, required: true
21
25
  param :expiry, type: :string, required: true
22
26
  param :quantity, type: :integer, default: 50
@@ -35,8 +39,7 @@ module DhanHQ
35
39
  end
36
40
 
37
41
  def get_spot_price(ctx)
38
- ltp = ctx[:instrument].ltp
39
- ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
42
+ ctx[:spot_price] = ctx[:instrument].ltp
40
43
  ctx
41
44
  end
42
45
 
@@ -49,19 +52,13 @@ module DhanHQ
49
52
  spot = ctx[:spot_price]
50
53
  chain = ctx[:chain]
51
54
 
52
- ce_options = chain.select do |opt|
53
- opt[:option_type] == "CE" || opt["optionType"] == "CE"
54
- end
55
-
56
- atm = ce_options.min_by do |opt|
57
- strike = opt[:strike] || opt["strike"]
58
- (strike.to_f - spot).abs
59
- end
55
+ atm = nearest_strike(chain, spot)
56
+ raise ArgumentError, "Could not find ATM strike" unless atm
60
57
 
61
58
  ctx[:selected_option] = atm
62
- ctx[:security_id] = atm[:security_id] || atm["securityId"]
63
- ctx[:strike] = atm[:strike] || atm["strike"]
64
- ctx[:premium] = atm[:last_price] || atm["lastPrice"] || atm[:ltp] || atm["ltp"]
59
+ ctx[:security_id] = leg_security_id(atm, "CE")
60
+ ctx[:strike] = atm[:strike]
61
+ ctx[:premium] = leg_premium(atm, "CE")
65
62
  ctx
66
63
  end
67
64
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a covered call strategy (buy 100 shares, sell 1 OTM call).
7
+ #
8
+ # Steps: find equity instrument → spot price → option chain →
9
+ # select OTM call strike → build intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("covered_call",
13
+ # symbol: "RELIANCE",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 100
16
+ # )
17
+ #
18
+ class CoveredCall < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a covered call: buy the underlying equity, sell an OTM call against it."
22
+
23
+ param :symbol, type: :string, required: true
24
+ param :expiry, type: :string, required: true
25
+ param :quantity, type: :integer, default: 100
26
+ param :strike_offset, type: :number, default: 2.0
27
+ param :stop_loss, type: :number, default: nil
28
+ param :target, type: :number, default: nil
29
+
30
+ step :find_instrument, priority: 1
31
+ step :get_spot_price, priority: 2
32
+ step :get_option_chain, priority: 3
33
+ step :select_otm_call, priority: 4
34
+ step :build_intent, priority: 5
35
+
36
+ def find_instrument(ctx)
37
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::NSE_EQ, ctx[:symbol])
38
+ ctx
39
+ end
40
+
41
+ def get_spot_price(ctx)
42
+ ctx[:spot_price] = ctx[:instrument].ltp
43
+ ctx
44
+ end
45
+
46
+ def get_option_chain(ctx)
47
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
48
+ ctx
49
+ end
50
+
51
+ def select_otm_call(ctx)
52
+ spot = ctx[:spot_price].to_f
53
+ chain = ctx[:chain]
54
+ offset_pct = ctx[:strike_offset] / 100.0
55
+
56
+ target_strike = spot * (1 + offset_pct)
57
+ otm_call = nearest_strike(chain, target_strike)
58
+
59
+ raise ArgumentError, "Could not find suitable OTM call strike near #{target_strike}" unless otm_call
60
+
61
+ ctx[:call_strike] = otm_call[:strike]
62
+ ctx[:call_security_id] = leg_security_id(otm_call, "CE")
63
+ ctx[:call_premium] = leg_premium(otm_call, "CE")
64
+ ctx[:equity_security_id] = ctx[:instrument].security_id
65
+ ctx
66
+ end
67
+
68
+ def build_intent(ctx)
69
+ ctx[:intent] = {
70
+ trade_type: "COVERED_CALL",
71
+ symbol: ctx[:symbol],
72
+ quantity: ctx[:quantity],
73
+ legs: [
74
+ { action: DhanHQ::Constants::TransactionType::BUY, instrument_type: DhanHQ::Constants::InstrumentType::EQUITY, security_id: ctx[:equity_security_id],
75
+ quantity: ctx[:quantity] },
76
+ { action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: ctx[:call_strike], security_id: ctx[:call_security_id], quantity: ctx[:quantity], premium: ctx[:call_premium] }
77
+ ],
78
+ note: "Covered call prepared: Buy #{ctx[:quantity]} #{ctx[:symbol]}, Sell #{ctx[:quantity]} #{ctx[:call_strike]} CE. Await human confirmation."
79
+ }
80
+ ctx
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -16,6 +16,10 @@ module DhanHQ
16
16
  # )
17
17
  #
18
18
  class IronCondor < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build an iron condor: sell OTM call + sell OTM put, buy further OTM call + put for protection."
22
+
19
23
  param :symbol, type: :string, required: true
20
24
  param :expiry, type: :string, required: true
21
25
  param :quantity, type: :integer, default: 50
@@ -34,8 +38,7 @@ module DhanHQ
34
38
  end
35
39
 
36
40
  def get_spot_price(ctx)
37
- ltp = ctx[:instrument].ltp
38
- ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
41
+ ctx[:spot_price] = ctx[:instrument].ltp
39
42
  ctx
40
43
  end
41
44
 
@@ -47,29 +50,22 @@ module DhanHQ
47
50
  def select_strikes(ctx)
48
51
  spot = ctx[:spot_price]
49
52
  chain = ctx[:chain]
50
- wing = ctx[:wing_width]
51
-
52
- ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
53
- .sort_by { |o| (o[:strike] || o["strike"]).to_f }
54
- pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
55
- .sort_by { |o| (o[:strike] || o["strike"]).to_f }
56
-
57
- atm = ce_options.min_by { |o| (o[:strike] || o["strike"]).to_f - spot.to_f }
53
+ wing = ctx[:wing_width].to_f
58
54
 
59
- atm_strike = (atm[:strike] || atm["strike"]).to_f
55
+ atm_strike = nearest_strike(chain, spot)[:strike].to_f
60
56
 
61
- short_ce = ce_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike + wing)).abs < 0.001 }
62
- long_ce = ce_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike + (wing * 2))).abs < 0.001 }
63
- short_pe = pe_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike - wing)).abs < 0.001 }
64
- long_pe = pe_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike - (wing * 2))).abs < 0.001 }
57
+ short_ce = find_strike(chain, atm_strike + wing)
58
+ long_ce = find_strike(chain, atm_strike + (wing * 2))
59
+ short_pe = find_strike(chain, atm_strike - wing)
60
+ long_pe = find_strike(chain, atm_strike - (wing * 2))
65
61
 
66
62
  raise ArgumentError, "Could not build iron condor — insufficient strikes in chain" unless short_ce && long_ce && short_pe && long_pe
67
63
 
68
64
  ctx[:legs] = [
69
- { action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: atm_strike + wing, security_id: short_ce[:security_id] || short_ce["securityId"] },
70
- { action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: atm_strike + (wing * 2), security_id: long_ce[:security_id] || long_ce["securityId"] },
71
- { action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike: atm_strike - wing, security_id: short_pe[:security_id] || short_pe["securityId"] },
72
- { action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: atm_strike - (wing * 2), security_id: long_pe[:security_id] || long_pe["securityId"] }
65
+ { action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: short_ce[:strike], security_id: leg_security_id(short_ce, "CE") },
66
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: long_ce[:strike], security_id: leg_security_id(long_ce, "CE") },
67
+ { action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike: short_pe[:strike], security_id: leg_security_id(short_pe, "PE") },
68
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: long_pe[:strike], security_id: leg_security_id(long_pe, "PE") }
73
69
  ]
74
70
  ctx
75
71
  end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/RescueModifier
4
+ # rubocop:disable Naming/VariableNumber
5
+ # rubocop:disable Style/NumericPredicate
6
+ # rubocop:disable Lint/AmbiguousOperatorPrecedence
7
+
8
+ require "date"
9
+
10
+ module DhanHQ
11
+ module Skills
12
+ module Builtin
13
+ # Programmatic Agent Skill to process and summarize market data.
14
+ # Exposes computed technical indicators and options statistics rather than raw data.
15
+ class MarketDataSummarizer < Base
16
+ risk "read_only"
17
+ scope "market:read"
18
+ description "Summarize technicals and/or option chain (PCR, OI walls, ATM strikes) for a symbol."
19
+
20
+ param :underlying_symbol, type: :string, required: true, description: "Underlying ticker symbol (e.g. NIFTY, RELIANCE)"
21
+ param :mode, type: :string, default: "both", description: "Analysis mode: both, technicals, or option_chain"
22
+ param :interval, type: :string, default: DhanHQ::Constants::Validity::DAY, description: "Timeframe for technical indicators"
23
+ param :range_days, type: :integer, default: 30, description: "Lookback period in days for indicator calculation"
24
+ param :expiry, type: :string, default: "nearest", description: "Specific option expiry date (YYYY-MM-DD) or 'nearest'"
25
+ param :strike_range, type: :integer, default: 5, description: "Number of strikes to include above and below ATM"
26
+
27
+ step :resolve_instrument, priority: 1
28
+ step :fetch_technicals, priority: 2
29
+ step :fetch_option_chain_summary, priority: 3
30
+ step :prepare_final_summary, priority: 4
31
+
32
+ def resolve_instrument(ctx)
33
+ symbol = ctx[:underlying_symbol].to_s.upcase.strip
34
+ inst = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, symbol) rescue nil
35
+ inst ||= DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::NSE_EQ, symbol) rescue nil
36
+ inst ||= DhanHQ::Models::Instrument.find_anywhere(symbol) rescue nil
37
+
38
+ raise "Underlying symbol not found: #{symbol}" unless inst
39
+
40
+ ctx[:instrument] = inst
41
+ ctx
42
+ end
43
+
44
+ def fetch_technicals(ctx)
45
+ return ctx unless %w[both technicals].include?(ctx[:mode])
46
+
47
+ inst = ctx[:instrument]
48
+ to_date = Date.today.strftime("%Y-%m-%d")
49
+ from_date = (Date.today - [ctx[:range_days].to_i * 2, 100].max).strftime("%Y-%m-%d")
50
+
51
+ candles = DhanHQ::Models::HistoricalData.daily(
52
+ security_id: inst.security_id,
53
+ exchange_segment: inst.exchange_segment,
54
+ instrument: inst.instrument,
55
+ from_date: from_date,
56
+ to_date: to_date
57
+ ) rescue []
58
+
59
+ if candles.any?
60
+ closes = candles.map { |c| c[:close].to_f }
61
+ latest_close = closes.last
62
+
63
+ sma_20 = closes.size >= 20 ? (closes.last(20).sum / 20.0).round(2) : nil
64
+ sma_50 = closes.size >= 50 ? (closes.last(50).sum / 50.0).round(2) : nil
65
+ ret_5d = closes.size >= 6 ? (((closes.last / closes[-6]) - 1.0) * 100).round(2) : nil
66
+
67
+ # RSI (14) Calculation
68
+ rsi_14 = calculate_rsi(closes)
69
+
70
+ ctx[:technical_summary] = {
71
+ ltp: latest_close,
72
+ sma_20: sma_20,
73
+ sma_50: sma_50,
74
+ return_5d_pct: ret_5d,
75
+ rsi_14: rsi_14,
76
+ data_points_analyzed: closes.size
77
+ }
78
+ else
79
+ # Fallback to LTP quote if daily candles fail
80
+ quote = inst.ltp rescue {}
81
+ ctx[:technical_summary] = {
82
+ ltp: quote[:ltp] || quote["ltp"] || 0.0,
83
+ note: "Failed to load historical candles; loaded quote snapshot instead."
84
+ }
85
+ end
86
+ ctx
87
+ end
88
+
89
+ def fetch_option_chain_summary(ctx)
90
+ return ctx unless %w[both option_chain].include?(ctx[:mode])
91
+
92
+ inst = ctx[:instrument]
93
+ underlying_seg = inst.exchange_segment == DhanHQ::Constants::ExchangeSegment::IDX_I ? DhanHQ::Constants::ExchangeSegment::IDX_I : DhanHQ::Constants::ExchangeSegment::NSE_EQ
94
+
95
+ target_expiry = ctx[:expiry]
96
+ if target_expiry.to_s.empty? || target_expiry == "nearest"
97
+ expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(
98
+ underlying_scrip: inst.security_id.to_i,
99
+ underlying_seg: underlying_seg
100
+ )
101
+ target_expiry = expiries.first
102
+ end
103
+
104
+ return ctx if target_expiry.nil?
105
+
106
+ chain = DhanHQ::Models::OptionChain.fetch(
107
+ underlying_scrip: inst.security_id.to_i,
108
+ underlying_seg: underlying_seg,
109
+ expiry: target_expiry
110
+ )
111
+
112
+ if chain
113
+ spot = chain[:last_price] || ctx[:technical_summary]&.[](:ltp)
114
+ strikes = chain[:strikes] || []
115
+
116
+ # Locate ATM
117
+ closest_strike_data = strikes.min_by { |s| (s[:strike].to_f - spot).abs }
118
+ closest_idx = strikes.index(closest_strike_data)
119
+
120
+ # Filter ATM +/- strike_range
121
+ range = ctx[:strike_range].to_i
122
+ start_idx = [0, closest_idx - range].max
123
+ end_idx = [strikes.size - 1, closest_idx + range].min
124
+
125
+ filtered_strikes = strikes[start_idx..end_idx].map do |s|
126
+ {
127
+ strike: s[:strike],
128
+ ce: s[:call] ? { security_id: s[:call][:security_id], ltp: s[:call][:last_price], oi: s[:call][:oi] } : nil,
129
+ pe: s[:put] ? { security_id: s[:put][:security_id], ltp: s[:put][:last_price], oi: s[:put][:oi] } : nil
130
+ }
131
+ end
132
+
133
+ # PCR & OI Walls
134
+ total_ce_oi = strikes.sum { |s| s[:call]&.[](:oi).to_f }
135
+ total_pe_oi = strikes.sum { |s| s[:put]&.[](:oi).to_f }
136
+ pcr = total_ce_oi > 0 ? (total_pe_oi / total_ce_oi).round(3) : 0.0
137
+
138
+ ce_walls = strikes.reject { |s| s[:call].nil? }.sort_by { |s| -(s[:call][:oi] || 0) }.first(3).map { |s| { strike: s[:strike], oi: s[:call][:oi] } }
139
+ pe_walls = strikes.reject { |s| s[:put].nil? }.sort_by { |s| -(s[:put][:oi] || 0) }.first(3).map { |s| { strike: s[:strike], oi: s[:put][:oi] } }
140
+
141
+ ctx[:option_chain_summary] = {
142
+ expiry: target_expiry,
143
+ spot: spot,
144
+ pcr: pcr,
145
+ resistance_walls: ce_walls,
146
+ support_walls: pe_walls,
147
+ strikes: filtered_strikes
148
+ }
149
+ end
150
+ ctx
151
+ end
152
+
153
+ def prepare_final_summary(ctx)
154
+ ctx[:summary] = {
155
+ symbol: ctx[:underlying_symbol],
156
+ timestamp: Time.now.strftime("%Y-%m-%d %H:%M:%S"),
157
+ technicals: ctx[:technical_summary],
158
+ options: ctx[:option_chain_summary]
159
+ }
160
+ ctx
161
+ end
162
+
163
+ private
164
+
165
+ def calculate_rsi(closes)
166
+ return nil if closes.size < 15
167
+
168
+ gains = []
169
+ losses = []
170
+ closes.each_cons(2) do |prev, curr|
171
+ diff = curr - prev
172
+ gains << (diff > 0 ? diff : 0.0)
173
+ losses << (diff < 0 ? -diff : 0.0)
174
+ end
175
+
176
+ avg_gain = gains.first(14).sum / 14.0
177
+ avg_loss = losses.first(14).sum / 14.0
178
+
179
+ gains[14..].zip(losses[14..]).each do |g, l|
180
+ avg_gain = (avg_gain * 13 + g) / 14.0
181
+ avg_loss = (avg_loss * 13 + l) / 14.0
182
+ end
183
+
184
+ rs = avg_loss > 0 ? (avg_gain / avg_loss) : 100.0
185
+ (100.0 - (100.0 / (1.0 + rs))).round(2)
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ # rubocop:enable Style/RescueModifier
193
+ # rubocop:enable Naming/VariableNumber
194
+ # rubocop:enable Style/NumericPredicate
195
+ # rubocop:enable Lint/AmbiguousOperatorPrecedence
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a protective put strategy (buy stock, buy OTM put).
7
+ #
8
+ # Steps: find instrument → spot price → option chain →
9
+ # select OTM put → build intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("protective_put",
13
+ # symbol: "RELIANCE",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 100
16
+ # )
17
+ #
18
+ class ProtectivePut < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a protective put: buy the underlying equity, buy an OTM put as downside insurance."
22
+
23
+ param :symbol, type: :string, required: true
24
+ param :expiry, type: :string, required: true
25
+ param :quantity, type: :integer, default: 100
26
+ param :strike_offset, type: :number, default: 2.0
27
+ param :max_premium_pct, type: :number, default: 3.0
28
+
29
+ step :find_instrument, priority: 1
30
+ step :get_spot_price, priority: 2
31
+ step :get_option_chain, priority: 3
32
+ step :select_otm_put, priority: 4
33
+ step :build_intent, priority: 5
34
+
35
+ def find_instrument(ctx)
36
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::NSE_EQ, ctx[:symbol])
37
+ ctx
38
+ end
39
+
40
+ def get_spot_price(ctx)
41
+ ctx[:spot_price] = ctx[:instrument].ltp
42
+ ctx
43
+ end
44
+
45
+ def get_option_chain(ctx)
46
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
47
+ ctx
48
+ end
49
+
50
+ def select_otm_put(ctx)
51
+ spot = ctx[:spot_price].to_f
52
+ chain = ctx[:chain]
53
+ offset_pct = ctx[:strike_offset] / 100.0
54
+ max_prem = ctx[:max_premium_pct] / 100.0
55
+
56
+ target_strike = spot * (1 - offset_pct)
57
+ otm_put = nearest_strike(chain, target_strike)
58
+
59
+ raise ArgumentError, "Could not find suitable OTM put strike near #{target_strike}" unless otm_put
60
+
61
+ premium = leg_premium(otm_put, "PE").to_f
62
+ premium_pct = premium / spot
63
+
64
+ raise ArgumentError, "Put premium #{premium_pct * 100}% exceeds max #{ctx[:max_premium_pct]}%" if premium_pct > max_prem
65
+
66
+ ctx[:put_strike] = otm_put[:strike]
67
+ ctx[:put_security_id] = leg_security_id(otm_put, "PE")
68
+ ctx[:put_premium] = premium
69
+ ctx[:equity_security_id] = ctx[:instrument].security_id
70
+ ctx
71
+ end
72
+
73
+ def build_intent(ctx)
74
+ ctx[:intent] = {
75
+ trade_type: "PROTECTIVE_PUT",
76
+ symbol: ctx[:symbol],
77
+ quantity: ctx[:quantity],
78
+ legs: [
79
+ { action: DhanHQ::Constants::TransactionType::BUY, instrument_type: DhanHQ::Constants::InstrumentType::EQUITY, security_id: ctx[:equity_security_id],
80
+ quantity: ctx[:quantity] },
81
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: ctx[:put_strike], security_id: ctx[:put_security_id], quantity: ctx[:quantity], premium: ctx[:put_premium] }
82
+ ],
83
+ note: "Protective put prepared: Buy #{ctx[:quantity]} #{ctx[:symbol]}, Buy #{ctx[:quantity]} #{ctx[:put_strike]} PE. Await human confirmation."
84
+ }
85
+ ctx
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -12,18 +12,15 @@ module DhanHQ
12
12
  # puts result[:exited_count]
13
13
  #
14
14
  class SquareOffAll < Base
15
+ risk "destructive_write"
16
+ scope "orders:write"
17
+ description "Exit all open positions at market price."
18
+
15
19
  step :fetch_positions, priority: 1
16
20
  step :exit_positions, priority: 2
17
21
 
18
22
  def fetch_positions(ctx)
19
- ctx[:positions] = DhanHQ::Models::Position.all.reject do |p|
20
- qty = begin
21
- p[:net_quantity] || p["netQuantity"] || p.net_quantity
22
- rescue StandardError
23
- 0
24
- end
25
- qty.to_i.zero?
26
- end
23
+ ctx[:positions] = DhanHQ::Models::Position.all.reject { |p| p.net_qty.to_i.zero? }
27
24
  ctx
28
25
  end
29
26
 
@@ -14,6 +14,10 @@ module DhanHQ
14
14
  # )
15
15
  #
16
16
  class SquareOffPosition < Base
17
+ risk "destructive_write"
18
+ scope "orders:write"
19
+ description "Exit a specific open position by symbol and exchange segment."
20
+
17
21
  param :symbol, type: :string, required: true
18
22
  param :exchange_segment, type: :string, required: true
19
23
 
@@ -23,17 +27,15 @@ module DhanHQ
23
27
  def find_position(ctx)
24
28
  positions = DhanHQ::Models::Position.all
25
29
  target = positions.find do |p|
26
- seg = p[:exchange_segment] || p["exchange_segment"]
27
- sym = p[:trading_symbol] || p["tradingSymbol"] || p[:symbol] || p["symbol"]
28
- seg.to_s == ctx[:exchange_segment].to_s && sym.to_s.upcase == ctx[:symbol].to_s.upcase
30
+ p.exchange_segment.to_s == ctx[:exchange_segment].to_s && p.trading_symbol.to_s.upcase == ctx[:symbol].to_s.upcase
29
31
  end
30
32
 
31
33
  raise ArgumentError, "No open position found for #{ctx[:symbol]} on #{ctx[:exchange_segment]}" unless target
32
34
 
33
35
  ctx[:position] = target
34
- ctx[:security_id] = target[:security_id] || target["securityId"]
35
- ctx[:trading_symbol] = target[:trading_symbol] || target["tradingSymbol"]
36
- ctx[:net_quantity] = (target[:net_quantity] || target["netQuantity"] || target.net_quantity).to_i
36
+ ctx[:security_id] = target.security_id
37
+ ctx[:trading_symbol] = target.trading_symbol
38
+ ctx[:net_quantity] = target.net_qty.to_i
37
39
  ctx
38
40
  end
39
41