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
@@ -44,6 +44,72 @@ module DhanHQ
44
44
  :day_sell_value, :drv_expiry_date, :drv_option_type, :drv_strike_price,
45
45
  :cross_currency
46
46
 
47
+ # Returns a concise prompt-friendly summary of the position.
48
+ def to_prompt
49
+ parts = [
50
+ "#{position_type} #{net_qty}x #{trading_symbol || security_id}",
51
+ "on #{exchange_segment}/#{product_type}"
52
+ ]
53
+ parts << "buy_avg=#{buy_avg}" if buy_avg&.positive?
54
+ parts << "sell_avg=#{sell_avg}" if sell_avg&.positive?
55
+ parts << "realized_pnl=#{realized_profit}" if realized_profit
56
+ parts << "unrealized_pnl=#{unrealized_profit}" if unrealized_profit
57
+ parts << "expiry=#{drv_expiry_date}" if drv_expiry_date
58
+ parts << "strike=#{drv_strike_price}" if drv_strike_price&.positive?
59
+ parts << "type=#{drv_option_type}" if drv_option_type
60
+ parts.join(", ")
61
+ end
62
+
63
+ # Returns true if this is a long position.
64
+ def long?
65
+ position_type == DhanHQ::Constants::PositionType::LONG
66
+ end
67
+
68
+ # Returns true if this is a short position.
69
+ def short?
70
+ position_type == DhanHQ::Constants::PositionType::SHORT
71
+ end
72
+
73
+ # Returns true if this position is closed.
74
+ def closed?
75
+ position_type == DhanHQ::Constants::OrderStatus::CLOSED || net_qty.to_i.zero?
76
+ end
77
+
78
+ # Returns true if this position has an open quantity.
79
+ def open?
80
+ !closed? && net_qty.to_i != 0
81
+ end
82
+
83
+ # Returns true if this position is profitable (unrealized).
84
+ def profitable?
85
+ unrealized_profit.to_f.positive?
86
+ end
87
+
88
+ # Returns true if this position is in loss (unrealized).
89
+ def in_loss?
90
+ unrealized_profit.to_f.negative?
91
+ end
92
+
93
+ # Returns the total unrealized P&L including realized.
94
+ def total_pnl
95
+ realized_profit.to_f + unrealized_profit.to_f
96
+ end
97
+
98
+ # Returns true if this is an F&O position.
99
+ def derivatives?
100
+ drv_expiry_date.present? || drv_strike_price.to_f.positive?
101
+ end
102
+
103
+ # Returns true if this is an options position.
104
+ def options?
105
+ derivatives? && drv_option_type.present?
106
+ end
107
+
108
+ # Returns true if this is a futures position.
109
+ def futures?
110
+ derivatives? && drv_option_type.nil?
111
+ end
112
+
47
113
  class << self
48
114
  ##
49
115
  # Provides a shared instance of the Positions resource.
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Models
5
+ # Lightweight result returned by Instrument.search for agent-friendly security resolution.
6
+ class SearchResult < BaseModel
7
+ attributes :security_id, :symbol_name, :display_name, :exchange_segment, :instrument,
8
+ :instrument_type, :lot_size, :tick_size, :expiry_date, :strike_price,
9
+ :option_type, :underlying_symbol, :isin
10
+ end
11
+ end
12
+ end
@@ -44,6 +44,19 @@ module DhanHQ
44
44
  :create_time, :update_time, :exchange_time, :drv_expiry_date,
45
45
  :drv_option_type, :drv_strike_price
46
46
 
47
+ # Returns a concise prompt-friendly summary of the trade.
48
+ def to_prompt
49
+ parts = [
50
+ "#{transaction_type} #{traded_quantity}x #{trading_symbol || security_id}",
51
+ "@ ₹#{traded_price}",
52
+ "on #{exchange_segment}/#{product_type}"
53
+ ]
54
+ parts << "order=#{order_id}" if order_id
55
+ parts << "charges=₹#{total_charges}" if respond_to?(:total_charges) && total_charges
56
+ parts << "time=#{create_time}" if create_time
57
+ parts.join(", ")
58
+ end
59
+
47
60
  class << self
48
61
  ##
49
62
  # Provides a shared instance of the Trades resource for current day tradebook APIs.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../contracts/twap_order_contract"
4
+
5
+ module DhanHQ
6
+ module Models
7
+ ##
8
+ # Model for creating and managing TWAP Orders.
9
+ #
10
+ # TWAP (Time-Weighted Average Price) orders split a large order into smaller
11
+ # slices distributed evenly across a defined time window. This minimizes market
12
+ # impact and achieves execution closer to the period's average price.
13
+ #
14
+ # @note **Static IP Whitelisting**: TWAP order creation, modification, and
15
+ # cancellation APIs require Static IP whitelisting.
16
+ #
17
+ # @example Create a TWAP order
18
+ # order = DhanHQ::Models::TwapOrder.create(
19
+ # dhan_client_id: "1000000003",
20
+ # transaction_type: "SELL",
21
+ # exchange_segment: "NSE_EQ",
22
+ # product_type: "INTRADAY",
23
+ # order_type: "MARKET",
24
+ # validity: "DAY",
25
+ # security_id: "11536",
26
+ # quantity: 500,
27
+ # price: 0, # MARKET order
28
+ # slice_interval: 300, # 5 minutes
29
+ # start_time: "09:30:00",
30
+ # end_time: "15:00:00"
31
+ # )
32
+ # puts "TWAP Order ID: #{order.order_id} - #{order.order_status}"
33
+ #
34
+ # @example Modify a TWAP order (extend window)
35
+ # order = DhanHQ::Models::TwapOrder.find(order_id)
36
+ # order.modify(
37
+ # dhan_client_id: "1000000003",
38
+ # order_id: order_id,
39
+ # end_time: "15:30:00",
40
+ # slice_interval: 600
41
+ # )
42
+ #
43
+ # @example Cancel a TWAP order
44
+ # order = DhanHQ::Models::TwapOrder.find(order_id)
45
+ # order.cancel
46
+ #
47
+ class TwapOrder < BaseModel
48
+ include Concerns::ApiResponseHandler
49
+
50
+ attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
51
+ :transaction_type, :exchange_segment, :product_type, :order_type,
52
+ :validity, :trading_symbol, :security_id, :quantity,
53
+ :remaining_quantity, :price, :trigger_price, :after_market_order,
54
+ :slice_interval, :start_time, :end_time, :leg_name,
55
+ :create_time, :update_time, :exchange_time, :drv_expiry_date,
56
+ :drv_option_type, :drv_strike_price,
57
+ :oms_error_code, :oms_error_description, :average_traded_price, :filled_qty
58
+
59
+ class << self
60
+ # @return [DhanHQ::Resources::TwapOrders]
61
+ def resource
62
+ @resource ||= DhanHQ::Resources::TwapOrders.new
63
+ end
64
+
65
+ # Retrieves all TWAP orders for the day.
66
+ #
67
+ # @return [Array<TwapOrder>]
68
+ def all
69
+ response = resource.all
70
+ return [] unless response.is_a?(Array)
71
+
72
+ response.map { |o| new(o, skip_validation: true) }
73
+ end
74
+
75
+ # Retrieves a specific TWAP order by ID.
76
+ #
77
+ # @param order_id [String]
78
+ # @return [TwapOrder, nil]
79
+ def find(order_id)
80
+ response = resource.find(order_id)
81
+ return nil unless response.is_a?(Hash) && response.any?
82
+
83
+ new(response, skip_validation: true)
84
+ end
85
+
86
+ # Creates a new TWAP order.
87
+ #
88
+ # @param params [Hash{Symbol => String, Integer, Float}]
89
+ # @return [TwapOrder, nil]
90
+ def create(params)
91
+ normalized = snake_case(params)
92
+ config = DhanHQ.configuration
93
+ normalized[:dhan_client_id] ||= config.client_id if config&.client_id
94
+ validate_params!(normalized, DhanHQ::Contracts::TwapOrderCreateContract)
95
+ formatted = camelize_keys(normalized)
96
+ response = resource.create(formatted)
97
+ return nil unless response.is_a?(Hash) && response["orderId"]
98
+
99
+ new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
100
+ end
101
+ end
102
+
103
+ # Modifies an existing TWAP order.
104
+ #
105
+ # @param new_params [Hash{Symbol => String, Integer, Float}]
106
+ # @return [TwapOrder, nil]
107
+ def modify(new_params)
108
+ raise "Order ID is required to modify a TWAP order" unless order_id
109
+
110
+ DhanHQ.logger&.info("[DhanHQ::Models::TwapOrder] Modifying order #{order_id}")
111
+ full_params = snake_case(new_params)
112
+ config = DhanHQ.configuration
113
+ full_params[:dhan_client_id] ||= config.client_id if config&.client_id
114
+ full_params[:order_id] = order_id
115
+ validate_params!(full_params, DhanHQ::Contracts::TwapOrderModifyContract)
116
+ formatted = camelize_keys(full_params)
117
+ response = self.class.resource.update(order_id, formatted)
118
+ success = handle_api_response(response, success_key: "orderId",
119
+ context: "[DhanHQ::Models::TwapOrder] Modification")
120
+ return self.class.find(order_id) if success
121
+
122
+ nil
123
+ end
124
+
125
+ # Cancels the TWAP order.
126
+ #
127
+ # @return [Boolean] true when cancelled successfully
128
+ def cancel
129
+ raise "Order ID is required to cancel a TWAP order" unless order_id
130
+
131
+ response = self.class.resource.cancel(order_id)
132
+ response.is_a?(Hash) && response["orderStatus"] == DhanHQ::Constants::OrderStatus::CANCELLED
133
+ end
134
+ end
135
+ end
136
+ end
@@ -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