DhanHQ 2.8.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +7 -0
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +16 -5
  5. data/docs/RAILS_WEBSOCKET_INTEGRATION.md +1 -1
  6. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +1 -1
  7. data/docs/TECHNICAL_ANALYSIS.md +1 -1
  8. data/exe/DhanHQ +1 -1
  9. data/lib/DhanHQ/agent/order_preview.rb +50 -0
  10. data/lib/DhanHQ/agent/policy.rb +51 -0
  11. data/lib/DhanHQ/agent/tool_registry.rb +250 -0
  12. data/lib/DhanHQ/agent.rb +12 -0
  13. data/lib/DhanHQ/ai/context_builder.rb +145 -0
  14. data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
  15. data/lib/DhanHQ/ai.rb +27 -0
  16. data/lib/DhanHQ/auth.rb +0 -1
  17. data/lib/DhanHQ/client.rb +1 -3
  18. data/lib/DhanHQ/constants.rb +2 -0
  19. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
  20. data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
  21. data/lib/DhanHQ/core/auth_api.rb +0 -1
  22. data/lib/DhanHQ/errors.rb +4 -0
  23. data/lib/DhanHQ/events/base.rb +203 -0
  24. data/lib/DhanHQ/events/bus.rb +158 -0
  25. data/lib/DhanHQ/events.rb +40 -0
  26. data/lib/DhanHQ/indicators.rb +283 -0
  27. data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
  28. data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
  29. data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
  30. data/lib/DhanHQ/market_data.rb +25 -0
  31. data/lib/DhanHQ/mcp/server.rb +72 -0
  32. data/lib/DhanHQ/mcp.rb +10 -0
  33. data/lib/DhanHQ/models/funds.rb +12 -0
  34. data/lib/DhanHQ/models/holding.rb +42 -0
  35. data/lib/DhanHQ/models/iceberg_order.rb +139 -0
  36. data/lib/DhanHQ/models/instrument.rb +36 -0
  37. data/lib/DhanHQ/models/order.rb +95 -0
  38. data/lib/DhanHQ/models/position.rb +66 -0
  39. data/lib/DhanHQ/models/search_result.rb +12 -0
  40. data/lib/DhanHQ/models/trade.rb +13 -0
  41. data/lib/DhanHQ/models/twap_order.rb +136 -0
  42. data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
  43. data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
  44. data/lib/DhanHQ/option_analytics.rb +36 -0
  45. data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
  46. data/lib/DhanHQ/resources/twap_orders.rb +61 -0
  47. data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
  48. data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
  49. data/lib/DhanHQ/risk/checks/options.rb +46 -0
  50. data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
  51. data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
  52. data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
  53. data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +65 -0
  55. data/lib/DhanHQ/risk.rb +250 -0
  56. data/lib/DhanHQ/skills/base.rb +132 -0
  57. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
  58. data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
  59. data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
  60. data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
  61. data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
  62. data/lib/DhanHQ/skills/registry.rb +101 -0
  63. data/lib/DhanHQ/skills/workflow.rb +66 -0
  64. data/lib/DhanHQ/skills.rb +29 -0
  65. data/lib/DhanHQ/strategy/base.rb +189 -0
  66. data/lib/DhanHQ/strategy.rb +40 -0
  67. data/lib/DhanHQ/version.rb +1 -1
  68. data/lib/DhanHQ/ws/decoder.rb +57 -19
  69. data/lib/DhanHQ.rb +3 -0
  70. data/lib/dhan_hq/agent.rb +3 -0
  71. data/lib/dhan_hq/analysis.rb +9 -0
  72. data/lib/dhan_hq/mcp.rb +3 -0
  73. data/lib/dhan_hq/ta.rb +5 -0
  74. data/lib/dhan_hq.rb +27 -4
  75. data/lib/ta/technical_analysis.rb +3 -1
  76. data/skills/dhanhq-ruby/SKILL.md +74 -0
  77. data/skills/dhanhq-ruby/references/market_data.md +3 -0
  78. data/skills/dhanhq-ruby/references/orders.md +7 -0
  79. metadata +61 -20
@@ -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
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Strategy framework for building and backtesting trading strategies.
5
+ #
6
+ # Provides a DSL for defining entry/exit rules, risk management,
7
+ # and signal generation.
8
+ #
9
+ # @example Define a simple strategy
10
+ # class MyStrategy < DhanHQ::Strategy::Base
11
+ # entry_rule :golden_cross do |data|
12
+ # data.sma(20).last > data.sma(50).last &&
13
+ # data.sma(20).last(2) <= data.sma(50).last(2)
14
+ # end
15
+ #
16
+ # exit_rule :death_cross do |data|
17
+ # data.sma(20).last < data.sma(50).last
18
+ # end
19
+ # end
20
+ #
21
+ module Strategy
22
+ # Signal represents a trading signal generated by a strategy.
23
+ #
24
+ # @attr_reader [Symbol] type Signal type (:buy, :sell, :hold)
25
+ # @attr_reader [Float] strength Signal strength (0.0 to 1.0)
26
+ # @attr_reader [Hash] metadata Additional signal metadata
27
+ Signal = Struct.new(:type, :strength, :metadata) do
28
+ def buy?
29
+ type == :buy
30
+ end
31
+
32
+ def sell?
33
+ type == :sell
34
+ end
35
+
36
+ def hold?
37
+ type == :hold
38
+ end
39
+
40
+ def to_prompt
41
+ parts = ["signal=#{type}", "strength=#{strength}"]
42
+ parts << "reason=#{metadata[:reason]}" if metadata[:reason]
43
+ parts.join(", ")
44
+ end
45
+ end
46
+
47
+ # Base class for all strategies.
48
+ #
49
+ # Provides DSL for defining entry/exit rules and risk management.
50
+ class Base
51
+ attr_reader :name, :params, :position
52
+
53
+ def initialize(name: self.class.name, params: {})
54
+ @name = name
55
+ @params = params
56
+ @position = nil
57
+ @entry_rules = {}
58
+ @exit_rules = {}
59
+ @risk_rules = {}
60
+ @signals = []
61
+ end
62
+
63
+ # Define an entry rule.
64
+ #
65
+ # @param name [Symbol] Rule name
66
+ # @param priority [Integer] Rule priority (lower = higher priority)
67
+ # @param block [Proc] Rule condition block
68
+ def self.entry_rule(name, priority: 10, &block)
69
+ @entry_rules ||= {}
70
+ @entry_rules[name] = { priority: priority, block: block }
71
+ end
72
+
73
+ # Define an exit rule.
74
+ #
75
+ # @param name [Symbol] Rule name
76
+ # @param priority [Integer] Rule priority (lower = higher priority)
77
+ # @param block [Proc] Rule condition block
78
+ def self.exit_rule(name, priority: 10, &block)
79
+ @exit_rules ||= {}
80
+ @exit_rules[name] = { priority: priority, block: block }
81
+ end
82
+
83
+ # Define a risk rule.
84
+ #
85
+ # @param name [Symbol] Rule name
86
+ # @param block [Proc] Risk check block
87
+ def self.risk_rule(name, &block)
88
+ @risk_rules ||= {}
89
+ @risk_rules[name] = block
90
+ end
91
+
92
+ # Get all entry rules for this strategy class.
93
+ def self.entry_rules
94
+ @entry_rules || {}
95
+ end
96
+
97
+ # Get all exit rules for this strategy class.
98
+ def self.exit_rules
99
+ @exit_rules || {}
100
+ end
101
+
102
+ # Get all risk rules for this strategy class.
103
+ def self.risk_rules
104
+ @risk_rules || {}
105
+ end
106
+
107
+ # Evaluate entry rules against market data.
108
+ #
109
+ # @param data [DhanHQ::MarketData::OHLCSeries] Market data series
110
+ # @return [DhanHQ::Strategy::Signal] Generated signal
111
+ def evaluate_entry(data)
112
+ sorted_rules = self.class.entry_rules.sort_by { |_, v| v[:priority] }
113
+
114
+ sorted_rules.each do |rule_name, rule|
115
+ result = rule[:block].call(data, params)
116
+ next unless result
117
+
118
+ signal = Signal.new(
119
+ type: :buy,
120
+ strength: calculate_strength(result),
121
+ metadata: { rule: rule_name, data: data }
122
+ )
123
+ @signals << signal
124
+ return signal
125
+ end
126
+
127
+ Signal.new(type: :hold, strength: 0.0, metadata: { reason: :no_entry_signal })
128
+ end
129
+
130
+ # Evaluate exit rules against market data.
131
+ #
132
+ # @param data [DhanHQ::MarketData::OHLCSeries] Market data series
133
+ # @return [DhanHQ::Strategy::Signal] Generated signal
134
+ def evaluate_exit(data)
135
+ sorted_rules = self.class.exit_rules.sort_by { |_, v| v[:priority] }
136
+
137
+ sorted_rules.each do |rule_name, rule|
138
+ result = rule[:block].call(data, params)
139
+ next unless result
140
+
141
+ signal = Signal.new(
142
+ type: :sell,
143
+ strength: calculate_strength(result),
144
+ metadata: { rule: rule_name, data: data }
145
+ )
146
+ @signals << signal
147
+ return signal
148
+ end
149
+
150
+ Signal.new(type: :hold, strength: 0.0, metadata: { reason: :no_exit_signal })
151
+ end
152
+
153
+ # Check all risk rules.
154
+ #
155
+ # @param context [Hash] Risk check context
156
+ # @return [Array<Symbol>] List of violated rule names
157
+ def check_risks(context)
158
+ violations = []
159
+
160
+ self.class.risk_rules.each do |rule_name, block|
161
+ result = block.call(context, params)
162
+ violations << rule_name unless result
163
+ end
164
+
165
+ violations
166
+ end
167
+
168
+ # Get all signals generated by this strategy.
169
+ def signals
170
+ @signals.dup
171
+ end
172
+
173
+ # Get the last signal.
174
+ def last_signal
175
+ @signals.last
176
+ end
177
+
178
+ private
179
+
180
+ def calculate_strength(result)
181
+ case result
182
+ when true then 1.0
183
+ when Numeric then result.clamp(0.0, 1.0)
184
+ else 0.5
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy/base"
4
+
5
+ module DhanHQ
6
+ # Strategy framework for building and backtesting trading strategies.
7
+ #
8
+ # Provides a DSL for defining entry/exit rules, risk management,
9
+ # and signal generation.
10
+ #
11
+ # @example Define a simple strategy
12
+ # class GoldenCross < DhanHQ::Strategy::Base
13
+ # entry_rule :golden_cross do |data, _params|
14
+ # sma_20 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 20)
15
+ # sma_50 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 50)
16
+ #
17
+ # sma_20.last && sma_50.last &&
18
+ # sma_20.last > sma_50.last &&
19
+ # sma_20[-2] && sma_50[-2] &&
20
+ # sma_20[-2] <= sma_50[-2]
21
+ # end
22
+ #
23
+ # exit_rule :death_cross do |data, _params|
24
+ # sma_20 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 20)
25
+ # sma_50 = DhanHQ::Indicators::SMA.calculate(data.closes, period: 50)
26
+ #
27
+ # sma_20.last && sma_50.last && sma_20.last < sma_50.last
28
+ # end
29
+ #
30
+ # risk_rule :max_drawdown do |context, _params|
31
+ # context[:drawdown].abs < 0.1
32
+ # end
33
+ # end
34
+ #
35
+ # strategy = GoldenCross.new
36
+ # signal = strategy.evaluate_entry(data)
37
+ #
38
+ module Strategy
39
+ end
40
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "2.8.0"
5
+ VERSION = "3.0.1"
6
6
  end
@@ -26,21 +26,45 @@ module DhanHQ
26
26
  segstr = Segments.from_code(pkt[:exchange_segment])
27
27
  sid = pkt[:security_id].to_s
28
28
 
29
- # pp pkt
30
- case kind
31
- when :ticker
29
+ dispatch(pkt, kind, segstr, sid)
30
+ rescue StandardError => e
31
+ DhanHQ.logger&.debug("[DhanHQ::WS::Decoder] #{e.class}: #{e.message}")
32
+ nil
33
+ end
34
+
35
+ class << self
36
+ private
37
+
38
+ def dispatch(pkt, kind, segstr, sid)
39
+ case kind
40
+ when :ticker then decode_ticker(pkt, segstr, sid)
41
+ when :quote then decode_quote(pkt, segstr, sid)
42
+ when :full then decode_full(pkt, segstr, sid)
43
+ when :oi then decode_oi(pkt, segstr, sid)
44
+ when :prev_close then decode_prev_close(pkt, segstr, sid)
45
+ when :depth_bid, :depth_ask then decode_depth(pkt, kind, segstr, sid)
46
+ when :disconnect then handle_disconnect(pkt, segstr, sid)
47
+ else handle_unknown(pkt)
48
+ end
49
+ end
50
+
51
+ def decode_ticker(pkt, segstr, sid)
32
52
  {
33
53
  kind: :ticker, segment: segstr, security_id: sid,
34
54
  ltp: pkt[:ltp].to_f, ts: pkt[:ltt]&.to_i
35
55
  }
36
- when :quote
56
+ end
57
+
58
+ def decode_quote(pkt, segstr, sid)
37
59
  {
38
60
  kind: :quote, segment: segstr, security_id: sid,
39
61
  ltp: pkt[:ltp].to_f, ts: pkt[:ltt]&.to_i, atp: pkt[:atp].to_f,
40
62
  vol: pkt[:volume].to_i, ts_buy_qty: pkt[:total_buy_qty].to_i, ts_sell_qty: pkt[:total_sell_qty].to_i,
41
63
  day_open: pkt[:day_open]&.to_f, day_high: pkt[:day_high]&.to_f, day_low: pkt[:day_low]&.to_f, day_close: pkt[:day_close]&.to_f
42
64
  }
43
- when :full
65
+ end
66
+
67
+ def decode_full(pkt, segstr, sid)
44
68
  out = {
45
69
  kind: :full, segment: segstr, security_id: sid,
46
70
  ltp: pkt[:ltp].to_f, ts: pkt[:ltt]&.to_i, atp: pkt[:atp].to_f,
@@ -48,35 +72,49 @@ module DhanHQ
48
72
  oi: pkt[:open_interest]&.to_i, oi_high: pkt[:highest_open_interest]&.to_i, oi_low: pkt[:lowest_open_interest]&.to_i,
49
73
  day_open: pkt[:day_open]&.to_f, day_high: pkt[:day_high]&.to_f, day_low: pkt[:day_low]&.to_f, day_close: pkt[:day_close]&.to_f
50
74
  }
51
- # First depth level (if present)
52
- if (md = pkt[:market_depth]).respond_to?(:[]) && md[0]
53
- lvl = md[0]
54
- out[:bid] = lvl.respond_to?(:bid_price) ? lvl.bid_price.to_f : nil
55
- out[:ask] = lvl.respond_to?(:ask_price) ? lvl.ask_price.to_f : nil
56
- end
75
+ merge_depth(out, pkt[:market_depth])
57
76
  out
58
- when :oi
77
+ end
78
+
79
+ def decode_oi(pkt, segstr, sid)
59
80
  { kind: :oi, segment: segstr, security_id: sid, oi: pkt[:open_interest].to_i }
60
- when :prev_close
81
+ end
82
+
83
+ def decode_prev_close(pkt, segstr, sid)
61
84
  { kind: :prev_close, segment: segstr, security_id: sid, prev_close: pkt[:prev_close].to_f,
62
85
  oi_prev: pkt[:oi_prev].to_i }
63
- when :depth_bid, :depth_ask
86
+ end
87
+
88
+ def decode_depth(pkt, kind, segstr, sid)
64
89
  {
65
90
  kind: kind, segment: segstr, security_id: sid,
66
91
  bid_quantity: pkt[:bid_quantity], ask_quantity: pkt[:ask_quantity],
67
92
  no_of_bid_orders: pkt[:no_of_bid_orders], no_of_ask_orders: pkt[:no_of_ask_orders],
68
93
  bid: pkt[:bid_price], ask: pkt[:ask_price]
69
94
  }
70
- when :disconnect
95
+ end
96
+
97
+ def handle_disconnect(pkt, segstr, sid)
71
98
  DhanHQ.logger&.warn("[DhanHQ::WS] disconnect code=#{pkt[:disconnection_code]} seg=#{segstr} sid=#{sid}")
72
99
  nil
73
- else
100
+ end
101
+
102
+ def handle_unknown(pkt)
74
103
  DhanHQ.logger&.debug("[DhanHQ::WS] unknown feed kind code=#{pkt[:feed_response_code]}")
75
104
  nil
76
105
  end
77
- rescue StandardError => e
78
- DhanHQ.logger&.debug("[DhanHQ::WS::Decoder] #{e.class}: #{e.message}")
79
- nil
106
+
107
+ def merge_depth(out, market_depth)
108
+ return unless market_depth.respond_to?(:[]) && market_depth[0]
109
+
110
+ lvl = market_depth[0]
111
+ out[:bid] = lvl.respond_to?(:bid_price) ? lvl.bid_price.to_f : nil
112
+ out[:ask] = lvl.respond_to?(:ask_price) ? lvl.ask_price.to_f : nil
113
+ return unless lvl.respond_to?(:bid_quantity) && lvl.respond_to?(:ask_quantity)
114
+
115
+ out[:bid_qty] = lvl.bid_quantity.to_i
116
+ out[:ask_qty] = lvl.ask_quantity.to_i
117
+ end
80
118
  end
81
119
  end
82
120
  end
data/lib/DhanHQ.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dhan_hq"