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,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
@@ -5,10 +5,12 @@ require "concurrent"
5
5
  module DhanHQ
6
6
  # Coarse-grained in-memory throttler matching the platform rate limits.
7
7
  class RateLimiter
8
- # Per-interval thresholds keyed by API type.
8
+ # Per-interval thresholds keyed by API type, matching the published
9
+ # DhanHQ rate-limit table (Order APIs: 10/sec, 100,000/day;
10
+ # Data APIs: 5/sec, 7,000/day; Market Quote: 1/sec; Option Chain: 1 per 3 sec).
9
11
  RATE_LIMITS = {
10
- order_api: { per_second: 25, per_minute: 250, per_hour: 1000, per_day: 7000 },
11
- data_api: { per_second: 5, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 100_000 },
12
+ order_api: { per_second: 10, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 100_000 },
13
+ data_api: { per_second: 5, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: 7_000 },
12
14
  quote_api: { per_second: 1, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day: Float::INFINITY },
13
15
  option_chain: { per_second: 1.0 / 3, per_minute: 20, per_hour: 600, per_day: 4800 },
14
16
  non_trading_api: { per_second: 20, per_minute: Float::INFINITY, per_hour: Float::INFINITY,
@@ -13,6 +13,7 @@ module DhanHQ
13
13
 
14
14
  def create(params)
15
15
  ensure_live_trading!
16
+ run_risk_checks!(params)
16
17
  log_order_context("DHAN_ALERT_ORDER_ATTEMPT", params)
17
18
  super
18
19
  end
@@ -26,6 +26,7 @@ module DhanHQ
26
26
  # @return [Hash]
27
27
  def create(params)
28
28
  ensure_live_trading!
29
+ run_risk_checks!(params)
29
30
  log_order_context("DHAN_FOREVER_ORDER_ATTEMPT", params)
30
31
  post("/orders", params: params)
31
32
  end
@@ -0,0 +1,62 @@
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
+ run_risk_checks!(params)
28
+ log_order_context("DHAN_ICEBERG_ORDER_ATTEMPT", params)
29
+ post("", params: params)
30
+ end
31
+
32
+ # Fetches a specific iceberg order by ID.
33
+ #
34
+ # @param order_id [String]
35
+ # @return [Hash]
36
+ def find(order_id)
37
+ get("/#{order_id}")
38
+ end
39
+
40
+ # Updates an existing iceberg order.
41
+ #
42
+ # @param order_id [String]
43
+ # @param params [Hash]
44
+ # @return [Hash]
45
+ def update(order_id, params)
46
+ ensure_live_trading!
47
+ log_order_context("DHAN_ICEBERG_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
48
+ put("/#{order_id}", params: params)
49
+ end
50
+
51
+ # Cancels an iceberg order.
52
+ #
53
+ # @param order_id [String]
54
+ # @return [Hash]
55
+ def cancel(order_id)
56
+ ensure_live_trading!
57
+ log_order_context("DHAN_ICEBERG_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
58
+ delete("/#{order_id}")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -17,6 +17,7 @@ module DhanHQ
17
17
 
18
18
  def create(params)
19
19
  ensure_live_trading!
20
+ run_risk_checks!(params)
20
21
  log_order_context("DHAN_ORDER_ATTEMPT", params)
21
22
  validate_place_order!(params)
22
23
  post("", params: params)
@@ -31,6 +32,7 @@ module DhanHQ
31
32
 
32
33
  def slicing(params)
33
34
  ensure_live_trading!
35
+ run_risk_checks!(params)
34
36
  log_order_context("DHAN_ORDER_SLICING_ATTEMPT", params)
35
37
  validate_place_order!(params)
36
38
  post("/slicing", params: params)
@@ -19,6 +19,7 @@ module DhanHQ
19
19
  # @return [Hash] API response containing pnlExitStatus and message.
20
20
  def configure(params)
21
21
  ensure_live_trading!
22
+ run_risk_checks!(params)
22
23
  log_order_context("DHAN_PNL_EXIT_CONFIGURE_ATTEMPT", params)
23
24
  post("", params: params)
24
25
  end
@@ -28,6 +28,7 @@ module DhanHQ
28
28
  # @return [Hash]
29
29
  def create(params)
30
30
  ensure_live_trading!
31
+ run_risk_checks!(params)
31
32
  log_order_context("DHAN_SUPER_ORDER_ATTEMPT", params)
32
33
  post("", params: params)
33
34
  end
@@ -0,0 +1,62 @@
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
+ run_risk_checks!(params)
28
+ log_order_context("DHAN_TWAP_ORDER_ATTEMPT", params)
29
+ post("", params: params)
30
+ end
31
+
32
+ # Fetches a specific TWAP order by ID.
33
+ #
34
+ # @param order_id [String]
35
+ # @return [Hash]
36
+ def find(order_id)
37
+ get("/#{order_id}")
38
+ end
39
+
40
+ # Updates an existing TWAP order.
41
+ #
42
+ # @param order_id [String]
43
+ # @param params [Hash]
44
+ # @return [Hash]
45
+ def update(order_id, params)
46
+ ensure_live_trading!
47
+ log_order_context("DHAN_TWAP_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
48
+ put("/#{order_id}", params: params)
49
+ end
50
+
51
+ # Cancels a TWAP order.
52
+ #
53
+ # @param order_id [String]
54
+ # @return [Hash]
55
+ def cancel(order_id)
56
+ ensure_live_trading!
57
+ log_order_context("DHAN_TWAP_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
58
+ delete("/#{order_id}")
59
+ end
60
+ end
61
+ end
62
+ 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
+ # Enforces maximum portfolio concentration in any single symbol.
7
+ class Concentration
8
+ MAX_CONCENTRATION_PCT = 25.0
9
+
10
+ def self.run!(args:, **_unused)
11
+ symbol = args["trading_symbol"] || args["security_id"]
12
+ return unless symbol
13
+
14
+ funds = DhanHQ::Models::Funds.fetch
15
+ available = funds.available_balance.to_f
16
+ return if available <= 0
17
+
18
+ positions = DhanHQ::Models::Position.all
19
+ symbol_positions = positions.select do |p|
20
+ sym = p.trading_symbol || p.security_id
21
+ sym.to_s == symbol.to_s
22
+ end
23
+
24
+ current_exposure = symbol_positions.sum do |p|
25
+ p.net_qty.to_i.abs * p.cost_price.to_f
26
+ end
27
+
28
+ concentration_pct = (current_exposure / available) * 100.0
29
+ return if concentration_pct <= MAX_CONCENTRATION_PCT
30
+
31
+ raise DhanHQ::RiskViolation,
32
+ "Concentration #{concentration_pct.round(1)}% exceeds #{MAX_CONCENTRATION_PCT}% limit for #{symbol}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ 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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces daily maximum loss limit across all positions.
7
+ class MaxLoss
8
+ DAILY_MAX_LOSS = 50_000
9
+
10
+ def self.run!(**_unused)
11
+ positions = DhanHQ::Models::Position.all
12
+ total_unrealized_loss = positions.sum do |p|
13
+ p.unrealized_profit.to_f
14
+ end
15
+
16
+ return if total_unrealized_loss >= -DAILY_MAX_LOSS
17
+
18
+ raise DhanHQ::RiskViolation,
19
+ "Daily loss limit of ₹#{DAILY_MAX_LOSS} exceeded (current: ₹#{total_unrealized_loss.round(0)})"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end