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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +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 +250 -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/constants.rb +2 -0
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- 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 +72 -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 +36 -0
- 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/resources/iceberg_orders.rb +61 -0
- data/lib/DhanHQ/resources/twap_orders.rb +61 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -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/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 +65 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +132 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -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 +27 -2
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +74 -0
- data/skills/dhanhq-ruby/references/market_data.md +3 -0
- data/skills/dhanhq-ruby/references/orders.md +7 -0
- 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
|
data/lib/DhanHQ/models/trade.rb
CHANGED
|
@@ -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
|