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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +51 -0
- data/README.md +152 -4
- data/docs/CONSTANTS_REFERENCE.md +3 -2
- data/exe/dhanhq-mcp +7 -0
- data/lib/DhanHQ/agent/order_preview.rb +50 -0
- data/lib/DhanHQ/agent/policy.rb +51 -0
- data/lib/DhanHQ/agent/tool_registry.rb +299 -0
- data/lib/DhanHQ/agent.rb +12 -0
- data/lib/DhanHQ/ai/context_builder.rb +145 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
- data/lib/DhanHQ/ai.rb +27 -0
- data/lib/DhanHQ/auth.rb +0 -1
- data/lib/DhanHQ/client.rb +1 -3
- data/lib/DhanHQ/concerns/order_audit.rb +43 -1
- data/lib/DhanHQ/constants.rb +5 -2
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
- data/lib/DhanHQ/core/auth_api.rb +0 -1
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/events/base.rb +203 -0
- data/lib/DhanHQ/events/bus.rb +158 -0
- data/lib/DhanHQ/events.rb +40 -0
- data/lib/DhanHQ/indicators.rb +283 -0
- data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
- data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
- data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
- data/lib/DhanHQ/market_data.rb +25 -0
- data/lib/DhanHQ/mcp/server.rb +235 -0
- data/lib/DhanHQ/mcp.rb +10 -0
- data/lib/DhanHQ/models/funds.rb +12 -0
- data/lib/DhanHQ/models/holding.rb +42 -0
- data/lib/DhanHQ/models/iceberg_order.rb +139 -0
- data/lib/DhanHQ/models/instrument.rb +80 -14
- data/lib/DhanHQ/models/order.rb +95 -0
- data/lib/DhanHQ/models/position.rb +66 -0
- data/lib/DhanHQ/models/search_result.rb +12 -0
- data/lib/DhanHQ/models/trade.rb +13 -0
- data/lib/DhanHQ/models/twap_order.rb +136 -0
- data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
- data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
- data/lib/DhanHQ/option_analytics.rb +36 -0
- data/lib/DhanHQ/rate_limiter.rb +5 -3
- data/lib/DhanHQ/resources/alert_orders.rb +1 -0
- data/lib/DhanHQ/resources/forever_orders.rb +1 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +62 -0
- data/lib/DhanHQ/resources/orders.rb +2 -0
- data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
- data/lib/DhanHQ/resources/super_orders.rb +1 -0
- data/lib/DhanHQ/resources/twap_orders.rb +62 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/options.rb +46 -0
- data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
- data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
- data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
- data/lib/DhanHQ/risk/pipeline.rb +72 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +183 -0
- data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +84 -0
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +89 -0
- data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
- data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +42 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +50 -0
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
- data/lib/DhanHQ/skills/registry.rb +101 -0
- data/lib/DhanHQ/skills/workflow.rb +66 -0
- data/lib/DhanHQ/skills.rb +29 -0
- data/lib/DhanHQ/strategy/base.rb +189 -0
- data/lib/DhanHQ/strategy.rb +40 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/decoder.rb +57 -19
- data/lib/DhanHQ.rb +3 -0
- data/lib/dhan_hq/agent.rb +3 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq.rb +74 -2
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +207 -0
- data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
- data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
- data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
- data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
- data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
- data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
- data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
- data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
- data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
- data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
- data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
- data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
- data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
- data/skills/dhanhq-ruby/references/error-codes.md +50 -0
- data/skills/dhanhq-ruby/references/funds.md +67 -0
- data/skills/dhanhq-ruby/references/instruments.md +85 -0
- data/skills/dhanhq-ruby/references/live-feed.md +83 -0
- data/skills/dhanhq-ruby/references/market-data.md +119 -0
- data/skills/dhanhq-ruby/references/option-chain.md +71 -0
- data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
- data/skills/dhanhq-ruby/references/orders.md +201 -0
- data/skills/dhanhq-ruby/references/portfolio.md +93 -0
- data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
- data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
- data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
- data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
- data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
- 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
|
data/lib/DhanHQ/rate_limiter.rb
CHANGED
|
@@ -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:
|
|
11
|
-
data_api: { per_second: 5, per_minute: Float::INFINITY, per_hour: Float::INFINITY, per_day:
|
|
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,
|
|
@@ -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)
|
|
@@ -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
|