DhanHQ 3.0.0 → 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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/CHANGELOG.md +51 -0
  5. data/README.md +152 -4
  6. data/docs/CONSTANTS_REFERENCE.md +3 -2
  7. data/exe/dhanhq-mcp +7 -0
  8. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  9. data/lib/DhanHQ/agent/policy.rb +51 -0
  10. data/lib/DhanHQ/agent/tool_registry.rb +299 -0
  11. data/lib/DhanHQ/agent.rb +12 -0
  12. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  13. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  14. data/lib/DhanHQ/ai.rb +27 -0
  15. data/lib/DhanHQ/auth.rb +0 -1
  16. data/lib/DhanHQ/client.rb +1 -3
  17. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  18. data/lib/DhanHQ/constants.rb +5 -2
  19. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  20. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  21. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  22. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  23. data/lib/DhanHQ/core/auth_api.rb +0 -1
  24. data/lib/DhanHQ/errors.rb +4 -0
  25. data/lib/DhanHQ/events/base.rb +203 -0
  26. data/lib/DhanHQ/events/bus.rb +158 -0
  27. data/lib/DhanHQ/events.rb +40 -0
  28. data/lib/DhanHQ/indicators.rb +283 -0
  29. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  30. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  31. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  32. data/lib/DhanHQ/market_data.rb +25 -0
  33. data/lib/DhanHQ/mcp/server.rb +235 -0
  34. data/lib/DhanHQ/mcp.rb +10 -0
  35. data/lib/DhanHQ/models/funds.rb +12 -0
  36. data/lib/DhanHQ/models/holding.rb +42 -0
  37. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  38. data/lib/DhanHQ/models/instrument.rb +80 -14
  39. data/lib/DhanHQ/models/order.rb +95 -0
  40. data/lib/DhanHQ/models/position.rb +66 -0
  41. data/lib/DhanHQ/models/search_result.rb +12 -0
  42. data/lib/DhanHQ/models/trade.rb +13 -0
  43. data/lib/DhanHQ/models/twap_order.rb +136 -0
  44. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  45. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  46. data/lib/DhanHQ/option_analytics.rb +36 -0
  47. data/lib/DhanHQ/rate_limiter.rb +5 -3
  48. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  49. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  50. data/lib/DhanHQ/resources/iceberg_orders.rb +62 -0
  51. data/lib/DhanHQ/resources/orders.rb +2 -0
  52. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  53. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  54. data/lib/DhanHQ/resources/twap_orders.rb +62 -0
  55. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  56. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  57. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  58. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  59. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  60. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  61. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  62. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  63. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  64. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  65. data/lib/DhanHQ/risk/pipeline.rb +72 -0
  66. data/lib/DhanHQ/risk.rb +250 -0
  67. data/lib/DhanHQ/skills/base.rb +183 -0
  68. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  69. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  70. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +84 -0
  71. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  72. data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
  73. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  74. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  75. data/lib/DhanHQ/skills/builtin/square_off_all.rb +42 -0
  76. data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
  77. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  78. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  79. data/lib/DhanHQ/skills/registry.rb +101 -0
  80. data/lib/DhanHQ/skills/workflow.rb +66 -0
  81. data/lib/DhanHQ/skills.rb +29 -0
  82. data/lib/DhanHQ/strategy/base.rb +189 -0
  83. data/lib/DhanHQ/strategy.rb +40 -0
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/ws/decoder.rb +57 -19
  86. data/lib/DhanHQ.rb +3 -0
  87. data/lib/dhan_hq/agent.rb +3 -0
  88. data/lib/dhan_hq/mcp.rb +3 -0
  89. data/lib/dhan_hq.rb +74 -2
  90. data/lib/ta/technical_analysis.rb +3 -1
  91. data/skills/dhanhq-ruby/SKILL.md +207 -0
  92. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  93. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  94. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  95. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  96. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  97. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  98. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  99. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  100. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  101. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  102. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  103. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  104. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  105. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  106. data/skills/dhanhq-ruby/references/funds.md +67 -0
  107. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  108. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  109. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  110. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  111. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  112. data/skills/dhanhq-ruby/references/orders.md +201 -0
  113. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  114. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  115. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  116. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  117. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  118. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  119. metadata +95 -20
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a bear call spread (sell OTM call, buy further OTM call).
7
+ #
8
+ # Steps: find instrument → spot price → option chain →
9
+ # select strikes → build intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("bear_call_spread",
13
+ # symbol: "NIFTY",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 50
16
+ # )
17
+ #
18
+ class BearCallSpread < Base
19
+ risk "trade_adjacent_read"
20
+ scope "orders:read"
21
+ description "Build a bear call spread: sell an OTM call, buy a further OTM call 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_call = find_strike(chain, atm_strike_price + spread)
58
+ long_call = find_strike(chain, atm_strike_price + (spread * 2))
59
+
60
+ raise ArgumentError, "Could not build bear call spread — insufficient strikes in chain" unless short_call && long_call
61
+
62
+ ctx[:legs] = [
63
+ { action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: short_call[:strike],
64
+ security_id: leg_security_id(short_call, "CE") },
65
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: long_call[:strike],
66
+ security_id: leg_security_id(long_call, "CE") }
67
+ ]
68
+ ctx
69
+ end
70
+
71
+ def build_intent(ctx)
72
+ ctx[:intent] = {
73
+ trade_type: "BEAR_CALL_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: "Bear call spread prepared. Await human confirmation before execution."
81
+ }
82
+ ctx
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to buy an ATM call option on an index.
7
+ #
8
+ # Steps: find instrument → get spot price → get option chain →
9
+ # select ATM strike → prepare trade intent.
10
+ #
11
+ # @example
12
+ # result = DhanHQ::Skills::Registry.call("buy_atm_call",
13
+ # symbol: "NIFTY",
14
+ # expiry: "2026-01-30",
15
+ # quantity: 50
16
+ # )
17
+ # puts result[:intent]
18
+ #
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
+
24
+ param :symbol, type: :string, required: true
25
+ param :expiry, type: :string, required: true
26
+ param :quantity, type: :integer, default: 50
27
+ param :stop_loss, type: :number, default: 100
28
+ param :target, type: :number, default: 200
29
+
30
+ step :find_instrument, priority: 1
31
+ step :get_spot_price, priority: 2
32
+ step :get_option_chain, priority: 3
33
+ step :select_atm_strike, priority: 4
34
+ step :prepare_intent, priority: 5
35
+
36
+ def find_instrument(ctx)
37
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, 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_atm_strike(ctx)
52
+ spot = ctx[:spot_price]
53
+ chain = ctx[:chain]
54
+
55
+ atm = nearest_strike(chain, spot)
56
+ raise ArgumentError, "Could not find ATM strike" unless atm
57
+
58
+ ctx[:selected_option] = atm
59
+ ctx[:security_id] = leg_security_id(atm, "CE")
60
+ ctx[:strike] = atm[:strike]
61
+ ctx[:premium] = leg_premium(atm, "CE")
62
+ ctx
63
+ end
64
+
65
+ def prepare_intent(ctx)
66
+ ctx[:intent] = {
67
+ trade_type: "OPTIONS_BUY",
68
+ instrument: "#{ctx[:symbol]} #{ctx[:strike]} CE",
69
+ security_id: ctx[:security_id],
70
+ strike: ctx[:strike],
71
+ expiry: ctx[:expiry],
72
+ option_type: "CE",
73
+ quantity: ctx[:quantity],
74
+ premium: ctx[:premium],
75
+ stop_loss: ctx[:stop_loss],
76
+ target: ctx[:target],
77
+ note: "Prepared ATM call buy. Await human confirmation."
78
+ }
79
+ ctx
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build an iron condor strategy (sell OTM CE + PE, buy further OTM CE + PE).
7
+ #
8
+ # Steps: find instrument → spot price → option chain → select strikes → build intent.
9
+ #
10
+ # @example
11
+ # result = DhanHQ::Skills::Registry.call("iron_condor",
12
+ # symbol: "NIFTY",
13
+ # expiry: "2026-01-30",
14
+ # quantity: 50,
15
+ # wing_width: 200
16
+ # )
17
+ #
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
+
23
+ param :symbol, type: :string, required: true
24
+ param :expiry, type: :string, required: true
25
+ param :quantity, type: :integer, default: 50
26
+ param :wing_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]
52
+ chain = ctx[:chain]
53
+ wing = ctx[:wing_width].to_f
54
+
55
+ atm_strike = nearest_strike(chain, spot)[:strike].to_f
56
+
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))
61
+
62
+ raise ArgumentError, "Could not build iron condor — insufficient strikes in chain" unless short_ce && long_ce && short_pe && long_pe
63
+
64
+ ctx[:legs] = [
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") }
69
+ ]
70
+ ctx
71
+ end
72
+
73
+ def build_intent(ctx)
74
+ ctx[:intent] = {
75
+ trade_type: "IRON_CONDOR",
76
+ symbol: ctx[:symbol],
77
+ expiry: ctx[:expiry],
78
+ quantity: ctx[:quantity],
79
+ wing_width: ctx[:wing_width],
80
+ max_loss: ctx[:max_loss],
81
+ legs: ctx[:legs],
82
+ note: "Iron condor prepared. Await human confirmation before execution."
83
+ }
84
+ ctx
85
+ end
86
+ end
87
+ end
88
+ end
89
+ 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