DhanHQ 3.0.0 → 3.0.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +7 -0
  3. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  4. data/lib/DhanHQ/agent/policy.rb +51 -0
  5. data/lib/DhanHQ/agent/tool_registry.rb +250 -0
  6. data/lib/DhanHQ/agent.rb +12 -0
  7. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  8. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  9. data/lib/DhanHQ/ai.rb +27 -0
  10. data/lib/DhanHQ/auth.rb +0 -1
  11. data/lib/DhanHQ/client.rb +1 -3
  12. data/lib/DhanHQ/constants.rb +2 -0
  13. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  14. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  15. data/lib/DhanHQ/core/auth_api.rb +0 -1
  16. data/lib/DhanHQ/errors.rb +4 -0
  17. data/lib/DhanHQ/events/base.rb +203 -0
  18. data/lib/DhanHQ/events/bus.rb +158 -0
  19. data/lib/DhanHQ/events.rb +40 -0
  20. data/lib/DhanHQ/indicators.rb +283 -0
  21. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  22. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  23. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  24. data/lib/DhanHQ/market_data.rb +25 -0
  25. data/lib/DhanHQ/mcp/server.rb +72 -0
  26. data/lib/DhanHQ/mcp.rb +10 -0
  27. data/lib/DhanHQ/models/funds.rb +12 -0
  28. data/lib/DhanHQ/models/holding.rb +42 -0
  29. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  30. data/lib/DhanHQ/models/instrument.rb +36 -0
  31. data/lib/DhanHQ/models/order.rb +95 -0
  32. data/lib/DhanHQ/models/position.rb +66 -0
  33. data/lib/DhanHQ/models/search_result.rb +12 -0
  34. data/lib/DhanHQ/models/trade.rb +13 -0
  35. data/lib/DhanHQ/models/twap_order.rb +136 -0
  36. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  37. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  38. data/lib/DhanHQ/option_analytics.rb +36 -0
  39. data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
  40. data/lib/DhanHQ/resources/twap_orders.rb +61 -0
  41. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  42. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  43. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  44. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  45. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  46. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  47. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  48. data/lib/DhanHQ/risk/pipeline.rb +65 -0
  49. data/lib/DhanHQ/risk.rb +250 -0
  50. data/lib/DhanHQ/skills/base.rb +132 -0
  51. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
  52. data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
  53. data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
  54. data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
  55. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  56. data/lib/DhanHQ/skills/registry.rb +101 -0
  57. data/lib/DhanHQ/skills/workflow.rb +66 -0
  58. data/lib/DhanHQ/skills.rb +29 -0
  59. data/lib/DhanHQ/strategy/base.rb +189 -0
  60. data/lib/DhanHQ/strategy.rb +40 -0
  61. data/lib/DhanHQ/version.rb +1 -1
  62. data/lib/DhanHQ/ws/decoder.rb +57 -19
  63. data/lib/DhanHQ.rb +3 -0
  64. data/lib/dhan_hq/agent.rb +3 -0
  65. data/lib/dhan_hq/mcp.rb +3 -0
  66. data/lib/dhan_hq.rb +27 -2
  67. data/lib/ta/technical_analysis.rb +3 -1
  68. data/skills/dhanhq-ruby/SKILL.md +74 -0
  69. data/skills/dhanhq-ruby/references/market_data.md +3 -0
  70. data/skills/dhanhq-ruby/references/orders.md +7 -0
  71. metadata +59 -20
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ # Base class for all trading skills.
6
+ #
7
+ # Provides a DSL for defining parameters, steps, and execution logic.
8
+ # Skills are stateless — context is passed through and returned.
9
+ #
10
+ # @example
11
+ # class BuyAtmCall < DhanHQ::Skills::Base
12
+ # param :symbol, type: :string, required: true
13
+ # param :expiry, type: :string, required: true
14
+ # param :quantity, type: :integer, default: 50
15
+ #
16
+ # step :find_instrument
17
+ # step :get_spot_price
18
+ # step :prepare_intent
19
+ #
20
+ # def find_instrument(ctx)
21
+ # ctx[:instrument] = DhanHQ::Models::Instrument.find("IDX_I", ctx[:symbol])
22
+ # ctx
23
+ # end
24
+ #
25
+ # def get_spot_price(ctx)
26
+ # ctx[:spot_price] = ctx[:instrument].ltp[:ltp]
27
+ # ctx
28
+ # end
29
+ #
30
+ # def prepare_intent(ctx)
31
+ # ctx[:intent] = { symbol: ctx[:symbol], spot: ctx[:spot_price] }
32
+ # ctx
33
+ # end
34
+ # end
35
+ #
36
+ class Base
37
+ class << self
38
+ # Define a parameter for this skill.
39
+ #
40
+ # @param name [Symbol] parameter name
41
+ # @param type [Symbol] :string, :integer, :number, :boolean
42
+ # @param required [Boolean] whether the parameter is required
43
+ # @param default [Object] default value if not provided
44
+ def param(name, type: :string, required: false, default: nil, description: nil)
45
+ @params ||= {}
46
+ @params[name] = { type: type, required: required, default: default, description: description }
47
+ end
48
+
49
+ # Define a step in the skill execution sequence.
50
+ #
51
+ # @param name [Symbol] method name to call
52
+ # @param priority [Integer] execution order (lower = earlier)
53
+ def step(name, priority: 10)
54
+ @steps ||= []
55
+ @steps << { name: name, priority: priority }
56
+ @steps.sort_by! { |s| s[:priority] }
57
+ end
58
+
59
+ # Accessor for defined parameters.
60
+ def params
61
+ @params || {}
62
+ end
63
+
64
+ # Accessor for defined steps.
65
+ def steps
66
+ @steps || []
67
+ end
68
+
69
+ # Validate that all required parameters are present.
70
+ #
71
+ # @param args [Hash] provided parameters
72
+ # @raise [ArgumentError] if required parameters are missing
73
+ def validate_params!(args)
74
+ params.each do |name, config|
75
+ next unless config[:required]
76
+
77
+ value = args[name] || args[name.to_s]
78
+ next unless value.nil?
79
+
80
+ raise ArgumentError, "Missing required parameter: #{name}"
81
+ end
82
+ end
83
+ end
84
+
85
+ # Execute the skill with the given arguments.
86
+ #
87
+ # @param args [Hash] skill parameters (symbol or string keys)
88
+ # @return [Hash] context with all accumulated state
89
+ # @raise [ArgumentError] if required parameters are missing
90
+ def call(args = {})
91
+ ctx = build_context(args)
92
+ self.class.validate_params!(ctx)
93
+
94
+ self.class.steps.each do |step|
95
+ result = send(step[:name], ctx)
96
+ ctx = result if result.is_a?(Hash)
97
+ end
98
+
99
+ ctx
100
+ end
101
+
102
+ # Skill name (defaults to class name).
103
+ def name
104
+ self.class.name || self.class.to_s
105
+ end
106
+
107
+ # Skill description (override in subclasses).
108
+ def description
109
+ self.class.to_s
110
+ end
111
+
112
+ # List of parameter definitions for this skill.
113
+ def param_definitions
114
+ self.class.params
115
+ end
116
+
117
+ private
118
+
119
+ def build_context(args)
120
+ ctx = {}
121
+
122
+ self.class.params.each do |name, config|
123
+ value = args[name] || args[name.to_s]
124
+ value = config[:default] if value.nil? && config[:default]
125
+ ctx[name] = value
126
+ end
127
+
128
+ ctx
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,87 @@
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
+ param :symbol, type: :string, required: true
21
+ param :expiry, type: :string, required: true
22
+ param :quantity, type: :integer, default: 50
23
+ param :stop_loss, type: :number, default: 100
24
+ param :target, type: :number, default: 200
25
+
26
+ step :find_instrument, priority: 1
27
+ step :get_spot_price, priority: 2
28
+ step :get_option_chain, priority: 3
29
+ step :select_atm_strike, priority: 4
30
+ step :prepare_intent, priority: 5
31
+
32
+ def find_instrument(ctx)
33
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
34
+ ctx
35
+ end
36
+
37
+ def get_spot_price(ctx)
38
+ ltp = ctx[:instrument].ltp
39
+ ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
40
+ ctx
41
+ end
42
+
43
+ def get_option_chain(ctx)
44
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
45
+ ctx
46
+ end
47
+
48
+ def select_atm_strike(ctx)
49
+ spot = ctx[:spot_price]
50
+ chain = ctx[:chain]
51
+
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
60
+
61
+ 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"]
65
+ ctx
66
+ end
67
+
68
+ def prepare_intent(ctx)
69
+ ctx[:intent] = {
70
+ trade_type: "OPTIONS_BUY",
71
+ instrument: "#{ctx[:symbol]} #{ctx[:strike]} CE",
72
+ security_id: ctx[:security_id],
73
+ strike: ctx[:strike],
74
+ expiry: ctx[:expiry],
75
+ option_type: "CE",
76
+ quantity: ctx[:quantity],
77
+ premium: ctx[:premium],
78
+ stop_loss: ctx[:stop_loss],
79
+ target: ctx[:target],
80
+ note: "Prepared ATM call buy. Await human confirmation."
81
+ }
82
+ ctx
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,93 @@
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
+ param :symbol, type: :string, required: true
20
+ param :expiry, type: :string, required: true
21
+ param :quantity, type: :integer, default: 50
22
+ param :wing_width, type: :number, default: 200
23
+ param :max_loss, type: :number, default: 5000
24
+
25
+ step :find_instrument, priority: 1
26
+ step :get_spot_price, priority: 2
27
+ step :get_option_chain, priority: 3
28
+ step :select_strikes, priority: 4
29
+ step :build_intent, priority: 5
30
+
31
+ def find_instrument(ctx)
32
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
33
+ ctx
34
+ end
35
+
36
+ def get_spot_price(ctx)
37
+ ltp = ctx[:instrument].ltp
38
+ ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
39
+ ctx
40
+ end
41
+
42
+ def get_option_chain(ctx)
43
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
44
+ ctx
45
+ end
46
+
47
+ def select_strikes(ctx)
48
+ spot = ctx[:spot_price]
49
+ 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 }
58
+
59
+ atm_strike = (atm[:strike] || atm["strike"]).to_f
60
+
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 }
65
+
66
+ raise ArgumentError, "Could not build iron condor — insufficient strikes in chain" unless short_ce && long_ce && short_pe && long_pe
67
+
68
+ 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"] }
73
+ ]
74
+ ctx
75
+ end
76
+
77
+ def build_intent(ctx)
78
+ ctx[:intent] = {
79
+ trade_type: "IRON_CONDOR",
80
+ symbol: ctx[:symbol],
81
+ expiry: ctx[:expiry],
82
+ quantity: ctx[:quantity],
83
+ wing_width: ctx[:wing_width],
84
+ max_loss: ctx[:max_loss],
85
+ legs: ctx[:legs],
86
+ note: "Iron condor prepared. Await human confirmation before execution."
87
+ }
88
+ ctx
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to exit all open positions at market price.
7
+ #
8
+ # Steps: fetch positions → exit each position → summarize results.
9
+ #
10
+ # @example
11
+ # result = DhanHQ::Skills::Registry.call("square_off_all")
12
+ # puts result[:exited_count]
13
+ #
14
+ class SquareOffAll < Base
15
+ step :fetch_positions, priority: 1
16
+ step :exit_positions, priority: 2
17
+
18
+ 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
27
+ ctx
28
+ end
29
+
30
+ def exit_positions(ctx)
31
+ results = ctx[:positions].map do
32
+ DhanHQ::Models::Position.exit_all!
33
+ rescue StandardError => e
34
+ { error: e.message }
35
+ end
36
+
37
+ ctx[:exit_results] = results
38
+ ctx[:exited_count] = results.count { |r| !(r.is_a?(Hash) && r.key?(:error)) }
39
+ ctx[:failed_count] = results.count { |r| r.is_a?(Hash) && r.key?(:error) }
40
+ ctx
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to exit a specific position by symbol.
7
+ #
8
+ # Steps: find position → exit position → return result.
9
+ #
10
+ # @example
11
+ # result = DhanHQ::Skills::Registry.call("square_off_position",
12
+ # symbol: "NIFTY",
13
+ # exchange_segment: "IDX_I"
14
+ # )
15
+ #
16
+ class SquareOffPosition < Base
17
+ param :symbol, type: :string, required: true
18
+ param :exchange_segment, type: :string, required: true
19
+
20
+ step :find_position, priority: 1
21
+ step :exit_position, priority: 2
22
+
23
+ def find_position(ctx)
24
+ positions = DhanHQ::Models::Position.all
25
+ 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
29
+ end
30
+
31
+ raise ArgumentError, "No open position found for #{ctx[:symbol]} on #{ctx[:exchange_segment]}" unless target
32
+
33
+ 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
37
+ ctx
38
+ end
39
+
40
+ def exit_position(ctx)
41
+ ctx[:exit_result] = DhanHQ::Models::Position.exit_all!
42
+ ctx[:exited] = true
43
+ ctx
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ module Builtin
6
+ # Skill to build a long strangle (buy OTM CE + buy OTM PE).
7
+ #
8
+ # Steps: find instrument → spot price → option chain → select strikes → build intent.
9
+ #
10
+ # @example
11
+ # result = DhanHQ::Skills::Registry.call("strangle",
12
+ # symbol: "NIFTY",
13
+ # expiry: "2026-01-30",
14
+ # quantity: 50,
15
+ # offset_pct: 1.0
16
+ # )
17
+ #
18
+ class Strangle < Base
19
+ param :symbol, type: :string, required: true
20
+ param :expiry, type: :string, required: true
21
+ param :quantity, type: :integer, default: 50
22
+ param :offset_pct, type: :number, default: 1.0
23
+ param :stop_loss, type: :number, default: 200
24
+ param :target, type: :number, default: 400
25
+
26
+ step :find_instrument, priority: 1
27
+ step :get_spot_price, priority: 2
28
+ step :get_option_chain, priority: 3
29
+ step :select_strikes, priority: 4
30
+ step :build_intent, priority: 5
31
+
32
+ def find_instrument(ctx)
33
+ ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
34
+ ctx
35
+ end
36
+
37
+ def get_spot_price(ctx)
38
+ ltp = ctx[:instrument].ltp
39
+ ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
40
+ ctx
41
+ end
42
+
43
+ def get_option_chain(ctx)
44
+ ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
45
+ ctx
46
+ end
47
+
48
+ def select_strikes(ctx)
49
+ spot = ctx[:spot_price].to_f
50
+ chain = ctx[:chain]
51
+ offset = ctx[:offset_pct] / 100.0
52
+
53
+ ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
54
+ pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
55
+
56
+ ce_strike = spot * (1 + offset)
57
+ pe_strike = spot * (1 - offset)
58
+
59
+ long_ce = ce_options.min_by { |o| ((o[:strike] || o["strike"]).to_f - ce_strike).abs }
60
+ long_pe = pe_options.min_by { |o| ((o[:strike] || o["strike"]).to_f - pe_strike).abs }
61
+
62
+ raise ArgumentError, "Could not find suitable CE strike near #{ce_strike}" unless long_ce
63
+ raise ArgumentError, "Could not find suitable PE strike near #{pe_strike}" unless long_pe
64
+
65
+ ctx[:ce_strike] = long_ce[:strike] || long_ce["strike"]
66
+ ctx[:pe_strike] = long_pe[:strike] || long_pe["strike"]
67
+ ctx[:ce_security_id] = long_ce[:security_id] || long_ce["securityId"]
68
+ ctx[:pe_security_id] = long_pe[:security_id] || long_pe["securityId"]
69
+ ctx[:ce_premium] = long_ce[:last_price] || long_ce["lastPrice"] || long_ce[:ltp]
70
+ ctx[:pe_premium] = long_pe[:last_price] || long_pe["lastPrice"] || long_pe[:ltp]
71
+ ctx
72
+ end
73
+
74
+ def build_intent(ctx)
75
+ ctx[:intent] = {
76
+ trade_type: "STRANGLE",
77
+ symbol: ctx[:symbol],
78
+ expiry: ctx[:expiry],
79
+ quantity: ctx[:quantity],
80
+ legs: [
81
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: ctx[:ce_strike], security_id: ctx[:ce_security_id], premium: ctx[:ce_premium] },
82
+ { action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: ctx[:pe_strike], security_id: ctx[:pe_security_id], premium: ctx[:pe_premium] }
83
+ ],
84
+ stop_loss: ctx[:stop_loss],
85
+ target: ctx[:target],
86
+ note: "Long strangle prepared. Await human confirmation."
87
+ }
88
+ ctx
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ # Registry for skill definitions.
6
+ #
7
+ # Maps skill names to their classes. Skills are registered explicitly
8
+ # or discovered via conventions.
9
+ #
10
+ # @example Register a skill
11
+ # DhanHQ::Skills::Registry.register("buy_atm_call", BuyAtmCall)
12
+ #
13
+ # @example Find a skill
14
+ # skill = DhanHQ::Skills::Registry.find("buy_atm_call")
15
+ # result = skill.call(symbol: "NIFTY", expiry: "2026-01-30")
16
+ #
17
+ class Registry
18
+ class << self
19
+ # Register a skill class by name.
20
+ #
21
+ # @param name [String] skill name
22
+ # @param klass [Class] skill class inheriting from Base
23
+ # @raise [ArgumentError] if the class is not a Base subclass
24
+ def register(name, klass)
25
+ raise ArgumentError, "Skill class must inherit from DhanHQ::Skills::Base" unless klass < Base
26
+
27
+ skills[name.to_s] = klass
28
+ end
29
+
30
+ # Find a skill by name.
31
+ #
32
+ # @param name [String] skill name
33
+ # @return [Class] the skill class
34
+ # @raise [KeyError] if skill is not found
35
+ def find(name)
36
+ skills.fetch(name.to_s) { raise KeyError, "Unknown skill: #{name}" }
37
+ end
38
+
39
+ # Execute a skill by name with the given arguments.
40
+ #
41
+ # @param name [String] skill name
42
+ # @param args [Hash] skill parameters
43
+ # @return [Hash] context with all accumulated state
44
+ def call(name, args = {})
45
+ find(name).new.call(args)
46
+ end
47
+
48
+ # List all registered skill names.
49
+ #
50
+ # @return [Array<String>]
51
+ def names
52
+ skills.keys.sort
53
+ end
54
+
55
+ # List all registered skills with metadata.
56
+ #
57
+ # @return [Array<Hash>]
58
+ def list
59
+ skills.map do |name, klass|
60
+ instance = klass.new
61
+ {
62
+ name: name,
63
+ description: instance.description,
64
+ params: instance.param_definitions,
65
+ steps: klass.steps.map { |s| s[:name] }
66
+ }
67
+ end
68
+ end
69
+
70
+ # Clear all registered skills (for testing).
71
+ def clear!
72
+ @skills = {}
73
+ end
74
+
75
+ # Load built-in skills from the builtin directory and register them.
76
+ def load_builtins
77
+ Dir[File.join(__dir__, "builtin", "*.rb")].each do |file|
78
+ require file
79
+ end
80
+
81
+ # Auto-register any Builtin classes that inherit from Base
82
+ DhanHQ::Skills::Builtin.constants.each do |const_name|
83
+ klass = DhanHQ::Skills::Builtin.const_get(const_name)
84
+ next unless klass.is_a?(Class) && klass < Base
85
+
86
+ name = const_name.to_s
87
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
88
+ .downcase
89
+ register(name, klass) unless skills.key?(name)
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def skills
96
+ @skills ||= {}
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Skills
5
+ # Workflow orchestrates multi-step skill execution with branching and error handling.
6
+ #
7
+ # A workflow is a sequence of steps that can succeed, fail, or branch
8
+ # based on the result of each step.
9
+ #
10
+ # @example Define a workflow
11
+ # workflow = DhanHQ::Skills::Workflow.new do
12
+ # step :check_funds do |ctx|
13
+ # funds = DhanHQ::Models::Funds.fetch
14
+ # ctx[:available_balance] = funds[:available_balance]
15
+ # ctx
16
+ # end
17
+ #
18
+ # step :validate_margin do |ctx|
19
+ # raise "Insufficient margin" if ctx[:available_balance] < 10_000
20
+ # ctx
21
+ # end
22
+ #
23
+ # step :place_order do |ctx|
24
+ # ctx[:order] = DhanHQ::Models::Order.place(ctx[:order_params])
25
+ # ctx
26
+ # end
27
+ # end
28
+ #
29
+ # result = workflow.call(order_params: { ... })
30
+ #
31
+ class Workflow
32
+ Step = Struct.new(:name, :block, :priority)
33
+
34
+ attr_reader :name, :steps
35
+
36
+ def initialize(name: "workflow", &block)
37
+ @name = name
38
+ @steps = []
39
+ instance_eval(&block) if block
40
+ end
41
+
42
+ # Define a step in the workflow.
43
+ #
44
+ # @param name [Symbol] step name
45
+ # @param priority [Integer] execution order (lower = earlier)
46
+ # @param block [Proc] step implementation
47
+ def step(name, priority: 10, &block)
48
+ @steps << Step.new(name, block, priority)
49
+ @steps.sort_by!(&:priority)
50
+ end
51
+
52
+ # Execute the workflow with the given context.
53
+ #
54
+ # @param ctx [Hash] initial context
55
+ # @return [Hash] final context after all steps
56
+ # @raise [RuntimeError] if any step fails
57
+ def call(ctx = {})
58
+ @steps.each do |step|
59
+ result = step.block.call(ctx)
60
+ ctx = result if result.is_a?(Hash)
61
+ end
62
+ ctx
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Multi-step trading workflows built on top of MCP tools.
5
+ #
6
+ # Skills compose individual tool calls into reusable trading strategies.
7
+ # Each skill defines a sequence of steps that execute in order, with
8
+ # context passed between steps.
9
+ #
10
+ # @example Define a custom skill
11
+ # class MySkill < DhanHQ::Skills::Base
12
+ # param :symbol, type: :string, required: true
13
+ # param :quantity, type: :integer, default: 1
14
+ #
15
+ # step :find_instrument
16
+ # step :place_order
17
+ #
18
+ # def find_instrument(ctx)
19
+ # ctx[:instrument] = DhanHQ::Models::Instrument.find("NSE_EQ", ctx[:symbol])
20
+ # end
21
+ #
22
+ # def place_order(ctx)
23
+ # DhanHQ::Models::Order.place(security_id: ctx[:instrument].security_id, quantity: ctx[:quantity])
24
+ # end
25
+ # end
26
+ #
27
+ module Skills
28
+ end
29
+ end