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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ # Option analytics calculations for derivatives trading.
5
+ module OptionAnalytics
6
+ # Black-Scholes option pricing model for calculating theoretical option prices and Greeks.
7
+ #
8
+ # @example Calculate option price
9
+ # price = DhanHQ::OptionAnalytics::BlackScholes.price(
10
+ # spot: 24000,
11
+ # strike: 24200,
12
+ # time_to_expiry: 0.038, # ~10 days
13
+ # risk_free_rate: 0.065,
14
+ # volatility: 0.15,
15
+ # option_type: :call
16
+ # )
17
+ #
18
+ class BlackScholes
19
+ # Calculate theoretical option price using Black-Scholes model.
20
+ #
21
+ # @param spot [Float] Current spot price
22
+ # @param strike [Float] Strike price
23
+ # @param time_to_expiry [Float] Time to expiry in years
24
+ # @param risk_free_rate [Float] Risk-free interest rate (annualized)
25
+ # @param volatility [Float] Implied volatility (annualized)
26
+ # @param option_type [Symbol] :call or :put
27
+ # @return [Float] Theoretical option price
28
+ def self.price(spot:, strike:, time_to_expiry:, risk_free_rate:, volatility:, option_type:)
29
+ return 0.0 if time_to_expiry <= 0
30
+
31
+ d1 = calculate_d1(spot, strike, time_to_expiry, risk_free_rate, volatility)
32
+ d2 = calculate_d2(d1, time_to_expiry, volatility)
33
+
34
+ if option_type == :call
35
+ (spot * normal_cdf(d1)) - (strike * Math.exp(-risk_free_rate * time_to_expiry) * normal_cdf(d2))
36
+ else
37
+ (strike * Math.exp(-risk_free_rate * time_to_expiry) * normal_cdf(-d2)) - (spot * normal_cdf(-d1))
38
+ end
39
+ end
40
+
41
+ # Calculate option Greeks (Delta, Gamma, Theta, Vega, Rho).
42
+ #
43
+ # @param spot [Float] Current spot price
44
+ # @param strike [Float] Strike price
45
+ # @param time_to_expiry [Float] Time to expiry in years
46
+ # @param risk_free_rate [Float] Risk-free interest rate (annualized)
47
+ # @param volatility [Float] Implied volatility (annualized)
48
+ # @param option_type [Symbol] :call or :put
49
+ # @return [Hash] Hash with :delta, :gamma, :theta, :vega, :rho
50
+ def self.greeks(spot:, strike:, time_to_expiry:, risk_free_rate:, volatility:, option_type:)
51
+ return empty_greeks if time_to_expiry <= 0
52
+
53
+ d1 = calculate_d1(spot, strike, time_to_expiry, risk_free_rate, volatility)
54
+ d2 = calculate_d2(d1, time_to_expiry, volatility)
55
+
56
+ gamma = normal_pdf(d1) / (spot * volatility * Math.sqrt(time_to_expiry))
57
+
58
+ theta = if option_type == :call
59
+ calculate_call_theta(spot, strike, time_to_expiry, risk_free_rate, volatility, d1, d2)
60
+ else
61
+ calculate_put_theta(spot, strike, time_to_expiry, risk_free_rate, volatility, d1, d2)
62
+ end
63
+
64
+ vega = spot * normal_pdf(d1) * Math.sqrt(time_to_expiry) / 100
65
+
66
+ rho = if option_type == :call
67
+ calculate_call_rho(spot, strike, time_to_expiry, risk_free_rate, d2)
68
+ else
69
+ calculate_put_rho(spot, strike, time_to_expiry, risk_free_rate, d2)
70
+ end
71
+
72
+ {
73
+ delta: calculate_delta(spot, strike, time_to_expiry, risk_free_rate, volatility, option_type),
74
+ gamma: gamma,
75
+ theta: theta / 365.0, # Daily theta
76
+ vega: vega,
77
+ rho: rho / 100.0
78
+ }
79
+ end
80
+
81
+ # Calculate implied volatility using Newton-Raphson method.
82
+ #
83
+ # @param market_price [Float] Observed market price of the option
84
+ # @param spot [Float] Current spot price
85
+ # @param strike [Float] Strike price
86
+ # @param time_to_expiry [Float] Time to expiry in years
87
+ # @param risk_free_rate [Float] Risk-free interest rate (annualized)
88
+ # @param option_type [Symbol] :call or :put
89
+ # @param tolerance [Float] Convergence tolerance (default: 0.0001)
90
+ # @param max_iterations [Integer] Maximum iterations (default: 100)
91
+ # @return [Float] Implied volatility
92
+ # rubocop:disable Metrics/ParameterLists
93
+ def self.implied_volatility(market_price:, spot:, strike:, time_to_expiry:, risk_free_rate:, option_type:,
94
+ tolerance: 0.0001, max_iterations: 100)
95
+ # rubocop:enable Metrics/ParameterLists
96
+ return 0.0 if time_to_expiry <= 0 || market_price <= 0
97
+
98
+ # Initial guess
99
+ iv = 0.2
100
+ max_iterations.times do
101
+ theoretical_price = price(
102
+ spot: spot, strike: strike, time_to_expiry: time_to_expiry,
103
+ risk_free_rate: risk_free_rate, volatility: iv, option_type: option_type
104
+ )
105
+
106
+ diff = theoretical_price - market_price
107
+ return iv if diff.abs < tolerance
108
+
109
+ # Vega for Newton-Raphson
110
+ vega = spot * normal_pdf(calculate_d1(spot, strike, time_to_expiry, risk_free_rate, iv)) *
111
+ Math.sqrt(time_to_expiry)
112
+
113
+ return iv if vega < 1e-10
114
+
115
+ iv -= diff / vega
116
+ iv = [iv, 0.001].max # Prevent negative volatility
117
+ end
118
+
119
+ iv
120
+ end
121
+
122
+ class << self
123
+ private
124
+
125
+ def calculate_d1(spot, strike, time_to_expiry, risk_free_rate, volatility)
126
+ (Math.log(spot / strike) + ((risk_free_rate + ((volatility**2) / 2)) * time_to_expiry)) /
127
+ (volatility * Math.sqrt(time_to_expiry))
128
+ end
129
+
130
+ def calculate_d2(d1_val, time_to_expiry, volatility)
131
+ d1_val - (volatility * Math.sqrt(time_to_expiry))
132
+ end
133
+
134
+ def calculate_delta(spot, strike, time_to_expiry, risk_free_rate, volatility, option_type)
135
+ d1_val = calculate_d1(spot, strike, time_to_expiry, risk_free_rate, volatility)
136
+
137
+ if option_type == :call
138
+ normal_cdf(d1_val)
139
+ else
140
+ normal_cdf(d1_val) - 1
141
+ end
142
+ end
143
+
144
+ def calculate_call_theta(spot, strike, time_to_expiry, risk_free_rate, volatility, d1_val, d2_val)
145
+ term1 = -(spot * normal_pdf(d1_val) * volatility) / (2 * Math.sqrt(time_to_expiry))
146
+ term2 = risk_free_rate * strike * Math.exp(-risk_free_rate * time_to_expiry) * normal_cdf(d2_val)
147
+ term1 - term2
148
+ end
149
+
150
+ def calculate_put_theta(spot, strike, time_to_expiry, risk_free_rate, volatility, d1_val, d2_val)
151
+ term1 = -(spot * normal_pdf(d1_val) * volatility) / (2 * Math.sqrt(time_to_expiry))
152
+ term2 = -risk_free_rate * strike * Math.exp(-risk_free_rate * time_to_expiry) * normal_cdf(-d2_val)
153
+ term1 - term2
154
+ end
155
+
156
+ def calculate_call_rho(_spot, strike, time_to_expiry, risk_free_rate, d2_val)
157
+ strike * time_to_expiry * Math.exp(-risk_free_rate * time_to_expiry) * normal_cdf(d2_val)
158
+ end
159
+
160
+ def calculate_put_rho(_spot, strike, time_to_expiry, risk_free_rate, d2_val)
161
+ -strike * time_to_expiry * Math.exp(-risk_free_rate * time_to_expiry) * normal_cdf(-d2_val)
162
+ end
163
+
164
+ def empty_greeks
165
+ { delta: 0.0, gamma: 0.0, theta: 0.0, vega: 0.0, rho: 0.0 }
166
+ end
167
+
168
+ # rubocop:disable Naming/MethodParameterName
169
+ # Standard normal cumulative distribution function
170
+ def normal_cdf(x)
171
+ 0.5 * (1 + erf(x / Math.sqrt(2)))
172
+ end
173
+
174
+ # Standard normal probability density function
175
+ def normal_pdf(x)
176
+ Math.exp(-0.5 * (x**2)) / Math.sqrt(2 * Math::PI)
177
+ end
178
+
179
+ # Error function approximation (Abramowitz and Stegun)
180
+ def erf(x)
181
+ sign = x.negative? ? -1 : 1
182
+ x = x.abs
183
+
184
+ t = 1.0 / (1.0 + (0.327_591_1 * x))
185
+ y = 1.0 - (((((((((1.061_40_5429 * t) - 1.453_152_027) * t) + 1.421_413_741) * t) -
186
+ 0.284_496_736) * t) + 0.254_829_592) * t * Math.exp(-x * x))
187
+
188
+ sign * y
189
+ end
190
+ # rubocop:enable Naming/MethodParameterName
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module OptionAnalytics
5
+ # Max Pain calculator for option chain analysis.
6
+ #
7
+ # Max Pain is the strike price at which the maximum number of options
8
+ # (both calls and puts) would expire worthless. It is believed that
9
+ # the underlying asset tends to gravitate towards this price at expiry.
10
+ #
11
+ # @example Calculate Max Pain
12
+ # option_data = [
13
+ # { strike: 24000, call_oi: 1000, put_oi: 500 },
14
+ # { strike: 24100, call_oi: 800, put_oi: 700 },
15
+ # { strike: 24200, call_oi: 600, put_oi: 900 },
16
+ # ...
17
+ # ]
18
+ # max_pain = DhanHQ::OptionAnalytics::MaxPain.calculate(option_data)
19
+ # #=> 24100
20
+ #
21
+ class MaxPain
22
+ # Calculate Max Pain strike price.
23
+ #
24
+ # @param option_data [Array<Hash>] Array of option data with :strike, :call_oi, :put_oi
25
+ # @return [Integer, Float] Strike price with maximum pain
26
+ def self.calculate(option_data)
27
+ return nil if option_data.nil? || option_data.empty?
28
+
29
+ strikes = option_data.map { |d| d[:strike] || d["strike"] }
30
+
31
+ # Calculate total pain for each possible strike
32
+ pain_values = strikes.map do |strike|
33
+ total_pain = option_data.sum do |data|
34
+ s = data[:strike] || data["strike"]
35
+ call_oi = (data[:call_oi] || data["call_oi"] || 0).to_i
36
+ put_oi = (data[:put_oi] || data["put_oi"] || 0).to_i
37
+
38
+ calculate_pain_at_strike(s, strike, call_oi, put_oi)
39
+ end
40
+
41
+ { strike: strike, pain: total_pain }
42
+ end
43
+
44
+ # Find strike with minimum pain (Max Pain)
45
+ pain_values.min_by { |v| v[:pain] }[:strike]
46
+ end
47
+
48
+ # Calculate Max Pain with detailed breakdown.
49
+ #
50
+ # @param option_data [Array<Hash>] Array of option data with :strike, :call_oi, :put_oi
51
+ # @return [Hash] Hash with :max_pain_strike, :total_pain, and :pain_distribution
52
+ def self.detailed(option_data)
53
+ return nil if option_data.nil? || option_data.empty?
54
+
55
+ strikes = option_data.map { |d| d[:strike] || d["strike"] }
56
+
57
+ pain_distribution = strikes.map do |strike|
58
+ total_pain = option_data.sum do |data|
59
+ s = data[:strike] || data["strike"]
60
+ call_oi = (data[:call_oi] || data["call_oi"] || 0).to_i
61
+ put_oi = (data[:put_oi] || data["put_oi"] || 0).to_i
62
+
63
+ calculate_pain_at_strike(s, strike, call_oi, put_oi)
64
+ end
65
+
66
+ { strike: strike, pain: total_pain }
67
+ end
68
+
69
+ max_pain_entry = pain_distribution.min_by { |v| v[:pain] }
70
+
71
+ {
72
+ max_pain_strike: max_pain_entry[:strike],
73
+ total_pain: max_pain_entry[:pain],
74
+ pain_distribution: pain_distribution
75
+ }
76
+ end
77
+
78
+ # Calculate Put-Call Ratio from option data.
79
+ #
80
+ # @param option_data [Array<Hash>] Array of option data with :call_oi, :put_oi
81
+ # @return [Float] Put-Call Ratio
82
+ def self.put_call_ratio(option_data)
83
+ return 0.0 if option_data.nil? || option_data.empty?
84
+
85
+ total_call_oi = option_data.sum { |d| (d[:call_oi] || d["call_oi"] || 0).to_i }
86
+ total_put_oi = option_data.sum { |d| (d[:put_oi] || d["put_oi"] || 0).to_i }
87
+
88
+ return 0.0 if total_call_oi.zero?
89
+
90
+ total_put_oi.to_f / total_call_oi
91
+ end
92
+
93
+ class << self
94
+ private
95
+
96
+ # Calculate total pain at a given strike price.
97
+ # Pain is the loss that option writers would incur if the underlying
98
+ # expires at that strike.
99
+ def calculate_pain_at_strike(current_strike, expiry_strike, call_oi, put_oi)
100
+ # Call writers lose when price goes above strike
101
+ call_pain = if expiry_strike > current_strike
102
+ (expiry_strike - current_strike) * call_oi
103
+ else
104
+ 0
105
+ end
106
+
107
+ # Put writers lose when price goes below strike
108
+ put_pain = if expiry_strike < current_strike
109
+ (current_strike - expiry_strike) * put_oi
110
+ else
111
+ 0
112
+ end
113
+
114
+ call_pain + put_pain
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "option_analytics/black_scholes"
4
+ require_relative "option_analytics/max_pain"
5
+
6
+ module DhanHQ
7
+ # Option analytics calculations for derivatives trading.
8
+ #
9
+ # Provides Black-Scholes pricing, Greeks calculation, implied volatility,
10
+ # and Max Pain analysis for option chain data.
11
+ #
12
+ # @example Calculate option price and Greeks
13
+ # price = DhanHQ::OptionAnalytics::BlackScholes.price(
14
+ # spot: 24000,
15
+ # strike: 24200,
16
+ # time_to_expiry: 0.038,
17
+ # risk_free_rate: 0.065,
18
+ # volatility: 0.15,
19
+ # option_type: :call
20
+ # )
21
+ #
22
+ # greeks = DhanHQ::OptionAnalytics::BlackScholes.greeks(
23
+ # spot: 24000,
24
+ # strike: 24200,
25
+ # time_to_expiry: 0.038,
26
+ # risk_free_rate: 0.065,
27
+ # volatility: 0.15,
28
+ # option_type: :call
29
+ # )
30
+ #
31
+ # @example Calculate Max Pain
32
+ # max_pain = DhanHQ::OptionAnalytics::MaxPain.calculate(option_data)
33
+ #
34
+ module OptionAnalytics
35
+ end
36
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../concerns/order_audit"
4
+
5
+ module DhanHQ
6
+ module Resources
7
+ # Resource client for Iceberg order management.
8
+ class IcebergOrders < BaseAPI
9
+ include DhanHQ::Concerns::OrderAudit
10
+
11
+ API_TYPE = :order_api
12
+ HTTP_PATH = "/v2/orders/iceberg"
13
+
14
+ # Lists all iceberg orders for the account.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def all
18
+ get("")
19
+ end
20
+
21
+ # Creates a new iceberg order.
22
+ #
23
+ # @param params [Hash]
24
+ # @return [Hash]
25
+ def create(params)
26
+ ensure_live_trading!
27
+ log_order_context("DHAN_ICEBERG_ORDER_ATTEMPT", params)
28
+ post("", params: params)
29
+ end
30
+
31
+ # Fetches a specific iceberg order by ID.
32
+ #
33
+ # @param order_id [String]
34
+ # @return [Hash]
35
+ def find(order_id)
36
+ get("/#{order_id}")
37
+ end
38
+
39
+ # Updates an existing iceberg order.
40
+ #
41
+ # @param order_id [String]
42
+ # @param params [Hash]
43
+ # @return [Hash]
44
+ def update(order_id, params)
45
+ ensure_live_trading!
46
+ log_order_context("DHAN_ICEBERG_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
47
+ put("/#{order_id}", params: params)
48
+ end
49
+
50
+ # Cancels an iceberg order.
51
+ #
52
+ # @param order_id [String]
53
+ # @return [Hash]
54
+ def cancel(order_id)
55
+ ensure_live_trading!
56
+ log_order_context("DHAN_ICEBERG_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
57
+ delete("/#{order_id}")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../concerns/order_audit"
4
+
5
+ module DhanHQ
6
+ module Resources
7
+ # Resource client for TWAP order management.
8
+ class TwapOrders < BaseAPI
9
+ include DhanHQ::Concerns::OrderAudit
10
+
11
+ API_TYPE = :order_api
12
+ HTTP_PATH = "/v2/orders/twap"
13
+
14
+ # Lists all TWAP orders for the account.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def all
18
+ get("")
19
+ end
20
+
21
+ # Creates a new TWAP order.
22
+ #
23
+ # @param params [Hash]
24
+ # @return [Hash]
25
+ def create(params)
26
+ ensure_live_trading!
27
+ log_order_context("DHAN_TWAP_ORDER_ATTEMPT", params)
28
+ post("", params: params)
29
+ end
30
+
31
+ # Fetches a specific TWAP order by ID.
32
+ #
33
+ # @param order_id [String]
34
+ # @return [Hash]
35
+ def find(order_id)
36
+ get("/#{order_id}")
37
+ end
38
+
39
+ # Updates an existing TWAP order.
40
+ #
41
+ # @param order_id [String]
42
+ # @param params [Hash]
43
+ # @return [Hash]
44
+ def update(order_id, params)
45
+ ensure_live_trading!
46
+ log_order_context("DHAN_TWAP_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
47
+ put("/#{order_id}", params: params)
48
+ end
49
+
50
+ # Cancels a TWAP order.
51
+ #
52
+ # @param order_id [String]
53
+ # @return [Hash]
54
+ def cancel(order_id)
55
+ ensure_live_trading!
56
+ log_order_context("DHAN_TWAP_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
57
+ delete("/#{order_id}")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Blocks trading on ASM/GSM restricted instruments.
7
+ class AsmGsm
8
+ def self.run!(instrument:, **_unused)
9
+ return unless instrument.asm_gsm_flag == "Y"
10
+
11
+ raise DhanHQ::RiskViolation,
12
+ "ASM/GSM restricted instrument (#{instrument.asm_gsm_category})"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Blocks orders outside Indian market hours (9:15 AM - 3:30 PM IST).
7
+ class MarketHours
8
+ TIMEZONE_OFFSET = "+05:30"
9
+ OPEN_HOUR = 9
10
+ OPEN_MINUTE = 15
11
+ CLOSE_HOUR = 15
12
+ CLOSE_MINUTE = 30
13
+
14
+ def self.run!(now: Time.now, **_unused)
15
+ market_now = now.getlocal(TIMEZONE_OFFSET)
16
+ return if market_open?(market_now)
17
+
18
+ raise DhanHQ::RiskViolation, "Market is closed"
19
+ end
20
+
21
+ def self.market_open?(now)
22
+ now.between?(market_open(now), market_close(now))
23
+ end
24
+
25
+ def self.market_open(now)
26
+ Time.new(now.year, now.month, now.day, OPEN_HOUR, OPEN_MINUTE, 0, TIMEZONE_OFFSET)
27
+ end
28
+
29
+ def self.market_close(now)
30
+ Time.new(now.year, now.month, now.day, CLOSE_HOUR, CLOSE_MINUTE, 0, TIMEZONE_OFFSET)
31
+ end
32
+
33
+ private_class_method :market_open?, :market_open, :market_close
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces option-specific risk rules (index-only, stop loss, target, risk-reward).
7
+ class Options
8
+ def self.run!(args:, instrument:, **_unused)
9
+ enforce_index!(instrument)
10
+ enforce_stop_loss!(args)
11
+ enforce_target!(args)
12
+ enforce_risk_reward!(args)
13
+ end
14
+
15
+ def self.enforce_index!(instrument)
16
+ return if instrument.instrument_type == "INDEX"
17
+
18
+ raise DhanHQ::RiskViolation, "Options only allowed on index"
19
+ end
20
+
21
+ def self.enforce_stop_loss!(args)
22
+ return if args["stop_loss"]
23
+
24
+ raise DhanHQ::RiskViolation, "Stop loss required"
25
+ end
26
+
27
+ def self.enforce_target!(args)
28
+ return if args["target"]
29
+
30
+ raise DhanHQ::RiskViolation, "Target required"
31
+ end
32
+
33
+ def self.enforce_risk_reward!(args)
34
+ stop_loss = args["stop_loss"].to_f
35
+ target = args["target"].to_f
36
+ return if target > stop_loss
37
+
38
+ raise DhanHQ::RiskViolation, "Invalid risk-reward"
39
+ end
40
+
41
+ private_class_method :enforce_index!, :enforce_stop_loss!,
42
+ :enforce_target!, :enforce_risk_reward!
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Restricts order types to MARKET and LIMIT only.
7
+ class OrderType
8
+ VALID_TYPES = %w[MARKET LIMIT].freeze
9
+
10
+ def self.run!(args:, **_unused)
11
+ order_type = args["order_type"]
12
+ return unless order_type
13
+ return if VALID_TYPES.include?(order_type)
14
+
15
+ raise DhanHQ::RiskViolation, "Invalid order type"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Validates that the instrument supports the requested product type (BO/CO).
7
+ class ProductSupport
8
+ def self.run!(args:, instrument:, **_unused)
9
+ product_type = args["product_type"]
10
+ return unless product_type
11
+
12
+ enforce_bracket_support!(product_type, instrument)
13
+ enforce_cover_support!(product_type, instrument)
14
+ end
15
+
16
+ def self.enforce_bracket_support!(product_type, instrument)
17
+ return unless product_type == DhanHQ::Constants::ProductType::BO
18
+ return if instrument.bracket_flag == "Y"
19
+
20
+ raise DhanHQ::RiskViolation, "Bracket orders not supported"
21
+ end
22
+
23
+ def self.enforce_cover_support!(product_type, instrument)
24
+ return unless product_type == DhanHQ::Constants::ProductType::CO
25
+ return if instrument.cover_flag == "Y"
26
+
27
+ raise DhanHQ::RiskViolation, "Cover orders not supported"
28
+ end
29
+
30
+ private_class_method :enforce_bracket_support!, :enforce_cover_support!
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces quantity and notional limits for MCP tool safety.
7
+ class Quantity
8
+ MAX_QUANTITY = 10
9
+ MAX_NOTIONAL = 100_000
10
+
11
+ def self.run!(args:, **_unused)
12
+ quantity = args["quantity"].to_i
13
+ raise DhanHQ::RiskViolation, "Quantity must be > 0" unless quantity.positive?
14
+ raise DhanHQ::RiskViolation, "Quantity exceeds limit" if quantity > MAX_QUANTITY
15
+
16
+ enforce_notional_limit!(quantity, args["price"])
17
+ end
18
+
19
+ def self.enforce_notional_limit!(quantity, price)
20
+ return unless price
21
+
22
+ notional = quantity * price.to_f
23
+ return if notional <= MAX_NOTIONAL
24
+
25
+ raise DhanHQ::RiskViolation, "Notional exceeds limit"
26
+ end
27
+
28
+ private_class_method :enforce_notional_limit!
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Blocks trading on instruments where buy_sell_indicator is not "A".
7
+ class TradingPermission
8
+ def self.run!(instrument:, **_unused)
9
+ return if instrument.buy_sell_indicator == "A"
10
+
11
+ raise DhanHQ::RiskViolation, "Trading disabled for instrument"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end