DhanHQ 2.8.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +7 -0
- data/CHANGELOG.md +19 -0
- data/README.md +16 -5
- data/docs/RAILS_WEBSOCKET_INTEGRATION.md +1 -1
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +1 -1
- data/docs/TECHNICAL_ANALYSIS.md +1 -1
- data/exe/DhanHQ +1 -1
- 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/analysis.rb +9 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq/ta.rb +5 -0
- data/lib/dhan_hq.rb +27 -4
- 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 +61 -20
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
# Pre-execution risk pipeline that validates orders before they reach the broker.
|
|
6
|
+
#
|
|
7
|
+
# Runs a sequence of checks against the instrument and order arguments.
|
|
8
|
+
# Raises DhanHQ::RiskViolation on the first failure.
|
|
9
|
+
#
|
|
10
|
+
# @example Run equity risk checks
|
|
11
|
+
# DhanHQ::Risk::Pipeline.run!(
|
|
12
|
+
# instrument: instrument,
|
|
13
|
+
# args: args,
|
|
14
|
+
# now: Time.now,
|
|
15
|
+
# type: :equity
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example Run options risk checks
|
|
19
|
+
# DhanHQ::Risk::Pipeline.run!(
|
|
20
|
+
# instrument: instrument,
|
|
21
|
+
# args: args,
|
|
22
|
+
# now: Time.now,
|
|
23
|
+
# type: :options
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
class Pipeline
|
|
27
|
+
CHECKS = [
|
|
28
|
+
Checks::TradingPermission,
|
|
29
|
+
Checks::AsmGsm,
|
|
30
|
+
Checks::ProductSupport,
|
|
31
|
+
Checks::OrderType,
|
|
32
|
+
Checks::Quantity,
|
|
33
|
+
Checks::MarketHours
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
OPTION_CHECKS = [
|
|
37
|
+
Checks::Options
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Run all applicable risk checks.
|
|
41
|
+
#
|
|
42
|
+
# @param instrument [Object] instrument with trading metadata
|
|
43
|
+
# @param args [Hash] order arguments (string keys)
|
|
44
|
+
# @param now [Time] current time for market hours check (default: Time.now)
|
|
45
|
+
# @param type [Symbol] :equity or :options
|
|
46
|
+
# @return [true] if all checks pass
|
|
47
|
+
# @raise [DhanHQ::RiskViolation] on first failure
|
|
48
|
+
# rubocop:disable Naming/PredicateMethod
|
|
49
|
+
def self.run!(instrument:, args:, now: Time.now, type: :equity)
|
|
50
|
+
run_checks!(CHECKS, instrument, args, now)
|
|
51
|
+
run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
# rubocop:enable Naming/PredicateMethod
|
|
55
|
+
|
|
56
|
+
def self.run_checks!(checks, instrument, args, now)
|
|
57
|
+
checks.each do |check|
|
|
58
|
+
check.run!(instrument: instrument, args: args, now: now)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method :run_checks!
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/DhanHQ/risk.rb
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Risk management utilities for position sizing and order risk calculation.
|
|
5
|
+
module Risk
|
|
6
|
+
# Calculate optimal position size based on risk parameters.
|
|
7
|
+
#
|
|
8
|
+
# Supports multiple sizing methods:
|
|
9
|
+
# - Fixed risk percentage of account
|
|
10
|
+
# - Kelly criterion
|
|
11
|
+
# - Volatility-based sizing
|
|
12
|
+
#
|
|
13
|
+
# @example Calculate position size with 2% risk
|
|
14
|
+
# size = DhanHQ::Risk::PositionSizer.calculate(
|
|
15
|
+
# account_balance: 100_000,
|
|
16
|
+
# risk_percent: 2.0,
|
|
17
|
+
# entry_price: 2500,
|
|
18
|
+
# stop_loss_price: 2450
|
|
19
|
+
# )
|
|
20
|
+
# #=> 40
|
|
21
|
+
#
|
|
22
|
+
class PositionSizer
|
|
23
|
+
# Calculate position size based on fixed risk percentage.
|
|
24
|
+
#
|
|
25
|
+
# @param account_balance [Float] Total account balance
|
|
26
|
+
# @param risk_percent [Float] Percentage of account to risk per trade (e.g., 2.0 for 2%)
|
|
27
|
+
# @param entry_price [Float] Planned entry price
|
|
28
|
+
# @param stop_loss_price [Float] Planned stop loss price
|
|
29
|
+
# @param lot_size [Integer] Lot size for the instrument (default: 1)
|
|
30
|
+
# @return [Integer] Number of shares/lots to trade
|
|
31
|
+
def self.calculate(account_balance:, risk_percent:, entry_price:, stop_loss_price:, lot_size: 1)
|
|
32
|
+
return 0 if account_balance <= 0 || entry_price <= 0 || stop_loss_price <= 0
|
|
33
|
+
return 0 if entry_price == stop_loss_price
|
|
34
|
+
|
|
35
|
+
risk_amount = account_balance * (risk_percent / 100.0)
|
|
36
|
+
risk_per_share = (entry_price - stop_loss_price).abs
|
|
37
|
+
|
|
38
|
+
return 0 if risk_per_share.zero?
|
|
39
|
+
|
|
40
|
+
raw_shares = (risk_amount / risk_per_share).floor
|
|
41
|
+
(raw_shares / lot_size).floor * lot_size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Calculate position size using Kelly Criterion.
|
|
45
|
+
#
|
|
46
|
+
# @param win_rate [Float] Historical win rate (0.0 to 1.0)
|
|
47
|
+
# @param avg_win [Float] Average winning trade amount
|
|
48
|
+
# @param avg_loss [Float] Average losing trade amount (positive number)
|
|
49
|
+
# @param account_balance [Float] Total account balance
|
|
50
|
+
# @param entry_price [Float] Planned entry price
|
|
51
|
+
# @param fraction [Float] Kelly fraction to use (default: 0.5 for half-Kelly)
|
|
52
|
+
# @return [Integer] Number of shares to trade
|
|
53
|
+
def self.kelly(win_rate:, avg_win:, avg_loss:, account_balance:, entry_price:, fraction: 0.5)
|
|
54
|
+
return 0 if account_balance <= 0 || entry_price <= 0
|
|
55
|
+
return 0 if avg_loss.zero? || win_rate <= 0 || win_rate >= 1
|
|
56
|
+
|
|
57
|
+
# Kelly formula: f = (bp - q) / b
|
|
58
|
+
# where b = avg_win/avg_loss, p = win_rate, q = 1 - win_rate
|
|
59
|
+
b = avg_win / avg_loss
|
|
60
|
+
kelly_fraction = ((b * win_rate) - (1 - win_rate)) / b
|
|
61
|
+
|
|
62
|
+
return 0 if kelly_fraction <= 0
|
|
63
|
+
|
|
64
|
+
# Apply fractional Kelly
|
|
65
|
+
adjusted_fraction = kelly_fraction * fraction
|
|
66
|
+
risk_amount = account_balance * adjusted_fraction
|
|
67
|
+
shares = (risk_amount / entry_price).floor
|
|
68
|
+
|
|
69
|
+
[shares, 0].max
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Calculate position size based on volatility (ATR-based).
|
|
73
|
+
#
|
|
74
|
+
# @param account_balance [Float] Total account balance
|
|
75
|
+
# @param risk_percent [Float] Percentage of account to risk per trade
|
|
76
|
+
# @param entry_price [Float] Planned entry price
|
|
77
|
+
# @param atr [Float] Current ATR value
|
|
78
|
+
# @param atr_multiplier [Float] Multiplier for ATR-based stop (default: 2.0)
|
|
79
|
+
# @param lot_size [Integer] Lot size for the instrument (default: 1)
|
|
80
|
+
# @return [Integer] Number of shares/lots to trade
|
|
81
|
+
def self.volatility_based(account_balance:, risk_percent:, entry_price:, atr:, atr_multiplier: 2.0, lot_size: 1)
|
|
82
|
+
return 0 if account_balance <= 0 || entry_price <= 0 || atr <= 0
|
|
83
|
+
|
|
84
|
+
stop_distance = atr * atr_multiplier
|
|
85
|
+
stop_loss_price = entry_price - stop_distance
|
|
86
|
+
|
|
87
|
+
calculate(
|
|
88
|
+
account_balance: account_balance,
|
|
89
|
+
risk_percent: risk_percent,
|
|
90
|
+
entry_price: entry_price,
|
|
91
|
+
stop_loss_price: stop_loss_price,
|
|
92
|
+
lot_size: lot_size
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Calculate stop loss and take profit levels.
|
|
98
|
+
#
|
|
99
|
+
# @example Calculate stop loss
|
|
100
|
+
# levels = DhanHQ::Risk::SLCalculator.stop_loss(
|
|
101
|
+
# entry_price: 2500,
|
|
102
|
+
# risk_percent: 2.0,
|
|
103
|
+
# atr: 50
|
|
104
|
+
# )
|
|
105
|
+
# #=> { fixed: 2450, atr_based: 2400 }
|
|
106
|
+
#
|
|
107
|
+
class SLCalculator
|
|
108
|
+
# Calculate fixed percentage stop loss.
|
|
109
|
+
#
|
|
110
|
+
# @param entry_price [Float] Entry price
|
|
111
|
+
# @param risk_percent [Float] Risk percentage (e.g., 2.0 for 2%)
|
|
112
|
+
# @return [Float] Stop loss price
|
|
113
|
+
def self.percentage(entry_price:, risk_percent:)
|
|
114
|
+
return 0.0 if entry_price <= 0 || risk_percent <= 0
|
|
115
|
+
|
|
116
|
+
entry_price * (1 - (risk_percent / 100.0))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate ATR-based stop loss.
|
|
120
|
+
#
|
|
121
|
+
# @param entry_price [Float] Entry price
|
|
122
|
+
# @param atr [Float] Current ATR value
|
|
123
|
+
# @param multiplier [Float] ATR multiplier (default: 2.0)
|
|
124
|
+
# @return [Float] Stop loss price
|
|
125
|
+
def self.atr_based(entry_price:, atr:, multiplier: 2.0)
|
|
126
|
+
return 0.0 if entry_price <= 0 || atr <= 0
|
|
127
|
+
|
|
128
|
+
entry_price - (atr * multiplier)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Calculate support-based stop loss.
|
|
132
|
+
#
|
|
133
|
+
# @param entry_price [Float] Entry price
|
|
134
|
+
# @param support_levels [Array<Float>] Array of support levels
|
|
135
|
+
# @param buffer [Float] Buffer below support (default: 0.5%)
|
|
136
|
+
# @return [Float] Stop loss price (below nearest support)
|
|
137
|
+
def self.support_based(entry_price:, support_levels:, buffer: 0.005)
|
|
138
|
+
return 0.0 if entry_price <= 0 || support_levels.nil? || support_levels.empty?
|
|
139
|
+
|
|
140
|
+
# Find nearest support below entry price
|
|
141
|
+
valid_supports = support_levels.select { |s| s < entry_price }
|
|
142
|
+
return 0.0 if valid_supports.empty?
|
|
143
|
+
|
|
144
|
+
nearest_support = valid_supports.max
|
|
145
|
+
nearest_support * (1 - buffer)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Calculate take profit based on risk-reward ratio.
|
|
149
|
+
#
|
|
150
|
+
# @param entry_price [Float] Entry price
|
|
151
|
+
# @param stop_loss_price [Float] Stop loss price
|
|
152
|
+
# @param risk_reward_ratio [Float] Desired risk:reward ratio (e.g., 2.0 for 1:2)
|
|
153
|
+
# @return [Float] Take profit price
|
|
154
|
+
def self.take_profit(entry_price:, stop_loss_price:, risk_reward_ratio: 2.0)
|
|
155
|
+
return 0.0 if entry_price <= 0 || stop_loss_price <= 0
|
|
156
|
+
|
|
157
|
+
risk = (entry_price - stop_loss_price).abs
|
|
158
|
+
entry_price + (risk * risk_reward_ratio)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Calculate trailing stop level.
|
|
162
|
+
#
|
|
163
|
+
# @param highest_price [Float] Highest price since entry
|
|
164
|
+
# @param atr [Float] Current ATR value
|
|
165
|
+
# @param multiplier [Float] ATR multiplier (default: 2.0)
|
|
166
|
+
# @return [Float] Trailing stop price
|
|
167
|
+
def self.trailing_stop(highest_price:, atr:, multiplier: 2.0)
|
|
168
|
+
return 0.0 if highest_price <= 0 || atr <= 0
|
|
169
|
+
|
|
170
|
+
highest_price - (atr * multiplier)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Manage trailing stop updates.
|
|
175
|
+
#
|
|
176
|
+
# @example Create trail manager
|
|
177
|
+
# trail = DhanHQ::Risk::TrailManager.new(
|
|
178
|
+
# entry_price: 2500,
|
|
179
|
+
# initial_stop: 2450,
|
|
180
|
+
# atr: 50,
|
|
181
|
+
# trail_multiplier: 2.0
|
|
182
|
+
# )
|
|
183
|
+
# trail.update(2600) #=> { stop: 2500, triggered: false }
|
|
184
|
+
#
|
|
185
|
+
class TrailManager
|
|
186
|
+
attr_reader :entry_price, :initial_stop, :current_stop, :highest_price, :atr, :trail_multiplier
|
|
187
|
+
|
|
188
|
+
def initialize(entry_price:, initial_stop:, atr:, trail_multiplier: 2.0)
|
|
189
|
+
@entry_price = entry_price
|
|
190
|
+
@initial_stop = initial_stop
|
|
191
|
+
@current_stop = initial_stop
|
|
192
|
+
@highest_price = entry_price
|
|
193
|
+
@atr = atr
|
|
194
|
+
@trail_multiplier = trail_multiplier
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Update trail with new price.
|
|
198
|
+
#
|
|
199
|
+
# @param current_price [Float] Current market price
|
|
200
|
+
# @return [Hash] Hash with :stop, :highest, :triggered
|
|
201
|
+
def update(current_price)
|
|
202
|
+
return { stop: @current_stop, highest: @highest_price, triggered: false } if current_price <= 0
|
|
203
|
+
|
|
204
|
+
# Update highest price
|
|
205
|
+
@highest_price = current_price if current_price > @highest_price
|
|
206
|
+
|
|
207
|
+
# Calculate new trailing stop
|
|
208
|
+
new_stop = @highest_price - (@atr * @trail_multiplier)
|
|
209
|
+
|
|
210
|
+
# Stop can only move up, never down
|
|
211
|
+
@current_stop = new_stop if new_stop > @current_stop
|
|
212
|
+
|
|
213
|
+
# Check if stop is triggered
|
|
214
|
+
triggered = current_price <= @current_stop
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
stop: @current_stop,
|
|
218
|
+
highest: @highest_price,
|
|
219
|
+
triggered: triggered
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Check if stop is triggered at current price.
|
|
224
|
+
#
|
|
225
|
+
# @param current_price [Float] Current market price
|
|
226
|
+
# @return [Boolean]
|
|
227
|
+
def triggered?(current_price)
|
|
228
|
+
current_price <= @current_stop
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Calculate profit from entry to current price.
|
|
232
|
+
#
|
|
233
|
+
# @param current_price [Float] Current market price
|
|
234
|
+
# @return [Float] Profit per share
|
|
235
|
+
def profit(current_price)
|
|
236
|
+
current_price - entry_price
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Calculate profit percentage from entry to current price.
|
|
240
|
+
#
|
|
241
|
+
# @param current_price [Float] Current market price
|
|
242
|
+
# @return [Float] Profit percentage
|
|
243
|
+
def profit_percent(current_price)
|
|
244
|
+
return 0.0 if entry_price.zero?
|
|
245
|
+
|
|
246
|
+
(profit(current_price) / entry_price * 100).round(2)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
# Base class for all trading skills.
|
|
6
|
+
#
|
|
7
|
+
# Provides a DSL for defining parameters, steps, and execution logic.
|
|
8
|
+
# Skills are stateless — context is passed through and returned.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class BuyAtmCall < DhanHQ::Skills::Base
|
|
12
|
+
# param :symbol, type: :string, required: true
|
|
13
|
+
# param :expiry, type: :string, required: true
|
|
14
|
+
# param :quantity, type: :integer, default: 50
|
|
15
|
+
#
|
|
16
|
+
# step :find_instrument
|
|
17
|
+
# step :get_spot_price
|
|
18
|
+
# step :prepare_intent
|
|
19
|
+
#
|
|
20
|
+
# def find_instrument(ctx)
|
|
21
|
+
# ctx[:instrument] = DhanHQ::Models::Instrument.find("IDX_I", ctx[:symbol])
|
|
22
|
+
# ctx
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# def get_spot_price(ctx)
|
|
26
|
+
# ctx[:spot_price] = ctx[:instrument].ltp[:ltp]
|
|
27
|
+
# ctx
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def prepare_intent(ctx)
|
|
31
|
+
# ctx[:intent] = { symbol: ctx[:symbol], spot: ctx[:spot_price] }
|
|
32
|
+
# ctx
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
class Base
|
|
37
|
+
class << self
|
|
38
|
+
# Define a parameter for this skill.
|
|
39
|
+
#
|
|
40
|
+
# @param name [Symbol] parameter name
|
|
41
|
+
# @param type [Symbol] :string, :integer, :number, :boolean
|
|
42
|
+
# @param required [Boolean] whether the parameter is required
|
|
43
|
+
# @param default [Object] default value if not provided
|
|
44
|
+
def param(name, type: :string, required: false, default: nil, description: nil)
|
|
45
|
+
@params ||= {}
|
|
46
|
+
@params[name] = { type: type, required: required, default: default, description: description }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Define a step in the skill execution sequence.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] method name to call
|
|
52
|
+
# @param priority [Integer] execution order (lower = earlier)
|
|
53
|
+
def step(name, priority: 10)
|
|
54
|
+
@steps ||= []
|
|
55
|
+
@steps << { name: name, priority: priority }
|
|
56
|
+
@steps.sort_by! { |s| s[:priority] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Accessor for defined parameters.
|
|
60
|
+
def params
|
|
61
|
+
@params || {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Accessor for defined steps.
|
|
65
|
+
def steps
|
|
66
|
+
@steps || []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Validate that all required parameters are present.
|
|
70
|
+
#
|
|
71
|
+
# @param args [Hash] provided parameters
|
|
72
|
+
# @raise [ArgumentError] if required parameters are missing
|
|
73
|
+
def validate_params!(args)
|
|
74
|
+
params.each do |name, config|
|
|
75
|
+
next unless config[:required]
|
|
76
|
+
|
|
77
|
+
value = args[name] || args[name.to_s]
|
|
78
|
+
next unless value.nil?
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "Missing required parameter: #{name}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Execute the skill with the given arguments.
|
|
86
|
+
#
|
|
87
|
+
# @param args [Hash] skill parameters (symbol or string keys)
|
|
88
|
+
# @return [Hash] context with all accumulated state
|
|
89
|
+
# @raise [ArgumentError] if required parameters are missing
|
|
90
|
+
def call(args = {})
|
|
91
|
+
ctx = build_context(args)
|
|
92
|
+
self.class.validate_params!(ctx)
|
|
93
|
+
|
|
94
|
+
self.class.steps.each do |step|
|
|
95
|
+
result = send(step[:name], ctx)
|
|
96
|
+
ctx = result if result.is_a?(Hash)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
ctx
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Skill name (defaults to class name).
|
|
103
|
+
def name
|
|
104
|
+
self.class.name || self.class.to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Skill description (override in subclasses).
|
|
108
|
+
def description
|
|
109
|
+
self.class.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# List of parameter definitions for this skill.
|
|
113
|
+
def param_definitions
|
|
114
|
+
self.class.params
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def build_context(args)
|
|
120
|
+
ctx = {}
|
|
121
|
+
|
|
122
|
+
self.class.params.each do |name, config|
|
|
123
|
+
value = args[name] || args[name.to_s]
|
|
124
|
+
value = config[:default] if value.nil? && config[:default]
|
|
125
|
+
ctx[name] = value
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
ctx
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to buy an ATM call option on an index.
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → get spot price → get option chain →
|
|
9
|
+
# select ATM strike → prepare trade intent.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = DhanHQ::Skills::Registry.call("buy_atm_call",
|
|
13
|
+
# symbol: "NIFTY",
|
|
14
|
+
# expiry: "2026-01-30",
|
|
15
|
+
# quantity: 50
|
|
16
|
+
# )
|
|
17
|
+
# puts result[:intent]
|
|
18
|
+
#
|
|
19
|
+
class BuyAtmCall < Base
|
|
20
|
+
param :symbol, type: :string, required: true
|
|
21
|
+
param :expiry, type: :string, required: true
|
|
22
|
+
param :quantity, type: :integer, default: 50
|
|
23
|
+
param :stop_loss, type: :number, default: 100
|
|
24
|
+
param :target, type: :number, default: 200
|
|
25
|
+
|
|
26
|
+
step :find_instrument, priority: 1
|
|
27
|
+
step :get_spot_price, priority: 2
|
|
28
|
+
step :get_option_chain, priority: 3
|
|
29
|
+
step :select_atm_strike, priority: 4
|
|
30
|
+
step :prepare_intent, priority: 5
|
|
31
|
+
|
|
32
|
+
def find_instrument(ctx)
|
|
33
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
|
|
34
|
+
ctx
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_spot_price(ctx)
|
|
38
|
+
ltp = ctx[:instrument].ltp
|
|
39
|
+
ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
|
|
40
|
+
ctx
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def get_option_chain(ctx)
|
|
44
|
+
ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
|
|
45
|
+
ctx
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def select_atm_strike(ctx)
|
|
49
|
+
spot = ctx[:spot_price]
|
|
50
|
+
chain = ctx[:chain]
|
|
51
|
+
|
|
52
|
+
ce_options = chain.select do |opt|
|
|
53
|
+
opt[:option_type] == "CE" || opt["optionType"] == "CE"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
atm = ce_options.min_by do |opt|
|
|
57
|
+
strike = opt[:strike] || opt["strike"]
|
|
58
|
+
(strike.to_f - spot).abs
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ctx[:selected_option] = atm
|
|
62
|
+
ctx[:security_id] = atm[:security_id] || atm["securityId"]
|
|
63
|
+
ctx[:strike] = atm[:strike] || atm["strike"]
|
|
64
|
+
ctx[:premium] = atm[:last_price] || atm["lastPrice"] || atm[:ltp] || atm["ltp"]
|
|
65
|
+
ctx
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepare_intent(ctx)
|
|
69
|
+
ctx[:intent] = {
|
|
70
|
+
trade_type: "OPTIONS_BUY",
|
|
71
|
+
instrument: "#{ctx[:symbol]} #{ctx[:strike]} CE",
|
|
72
|
+
security_id: ctx[:security_id],
|
|
73
|
+
strike: ctx[:strike],
|
|
74
|
+
expiry: ctx[:expiry],
|
|
75
|
+
option_type: "CE",
|
|
76
|
+
quantity: ctx[:quantity],
|
|
77
|
+
premium: ctx[:premium],
|
|
78
|
+
stop_loss: ctx[:stop_loss],
|
|
79
|
+
target: ctx[:target],
|
|
80
|
+
note: "Prepared ATM call buy. Await human confirmation."
|
|
81
|
+
}
|
|
82
|
+
ctx
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build an iron condor strategy (sell OTM CE + PE, buy further OTM CE + PE).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → spot price → option chain → select strikes → build intent.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# result = DhanHQ::Skills::Registry.call("iron_condor",
|
|
12
|
+
# symbol: "NIFTY",
|
|
13
|
+
# expiry: "2026-01-30",
|
|
14
|
+
# quantity: 50,
|
|
15
|
+
# wing_width: 200
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class IronCondor < Base
|
|
19
|
+
param :symbol, type: :string, required: true
|
|
20
|
+
param :expiry, type: :string, required: true
|
|
21
|
+
param :quantity, type: :integer, default: 50
|
|
22
|
+
param :wing_width, type: :number, default: 200
|
|
23
|
+
param :max_loss, type: :number, default: 5000
|
|
24
|
+
|
|
25
|
+
step :find_instrument, priority: 1
|
|
26
|
+
step :get_spot_price, priority: 2
|
|
27
|
+
step :get_option_chain, priority: 3
|
|
28
|
+
step :select_strikes, priority: 4
|
|
29
|
+
step :build_intent, priority: 5
|
|
30
|
+
|
|
31
|
+
def find_instrument(ctx)
|
|
32
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
|
|
33
|
+
ctx
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get_spot_price(ctx)
|
|
37
|
+
ltp = ctx[:instrument].ltp
|
|
38
|
+
ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
|
|
39
|
+
ctx
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_option_chain(ctx)
|
|
43
|
+
ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
|
|
44
|
+
ctx
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def select_strikes(ctx)
|
|
48
|
+
spot = ctx[:spot_price]
|
|
49
|
+
chain = ctx[:chain]
|
|
50
|
+
wing = ctx[:wing_width]
|
|
51
|
+
|
|
52
|
+
ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
|
|
53
|
+
.sort_by { |o| (o[:strike] || o["strike"]).to_f }
|
|
54
|
+
pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
|
|
55
|
+
.sort_by { |o| (o[:strike] || o["strike"]).to_f }
|
|
56
|
+
|
|
57
|
+
atm = ce_options.min_by { |o| (o[:strike] || o["strike"]).to_f - spot.to_f }
|
|
58
|
+
|
|
59
|
+
atm_strike = (atm[:strike] || atm["strike"]).to_f
|
|
60
|
+
|
|
61
|
+
short_ce = ce_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike + wing)).abs < 0.001 }
|
|
62
|
+
long_ce = ce_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike + (wing * 2))).abs < 0.001 }
|
|
63
|
+
short_pe = pe_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike - wing)).abs < 0.001 }
|
|
64
|
+
long_pe = pe_options.find { |o| ((o[:strike] || o["strike"]).to_f - (atm_strike - (wing * 2))).abs < 0.001 }
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "Could not build iron condor — insufficient strikes in chain" unless short_ce && long_ce && short_pe && long_pe
|
|
67
|
+
|
|
68
|
+
ctx[:legs] = [
|
|
69
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: atm_strike + wing, security_id: short_ce[:security_id] || short_ce["securityId"] },
|
|
70
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: atm_strike + (wing * 2), security_id: long_ce[:security_id] || long_ce["securityId"] },
|
|
71
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike: atm_strike - wing, security_id: short_pe[:security_id] || short_pe["securityId"] },
|
|
72
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: atm_strike - (wing * 2), security_id: long_pe[:security_id] || long_pe["securityId"] }
|
|
73
|
+
]
|
|
74
|
+
ctx
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_intent(ctx)
|
|
78
|
+
ctx[:intent] = {
|
|
79
|
+
trade_type: "IRON_CONDOR",
|
|
80
|
+
symbol: ctx[:symbol],
|
|
81
|
+
expiry: ctx[:expiry],
|
|
82
|
+
quantity: ctx[:quantity],
|
|
83
|
+
wing_width: ctx[:wing_width],
|
|
84
|
+
max_loss: ctx[:max_loss],
|
|
85
|
+
legs: ctx[:legs],
|
|
86
|
+
note: "Iron condor prepared. Await human confirmation before execution."
|
|
87
|
+
}
|
|
88
|
+
ctx
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to exit all open positions at market price.
|
|
7
|
+
#
|
|
8
|
+
# Steps: fetch positions → exit each position → summarize results.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# result = DhanHQ::Skills::Registry.call("square_off_all")
|
|
12
|
+
# puts result[:exited_count]
|
|
13
|
+
#
|
|
14
|
+
class SquareOffAll < Base
|
|
15
|
+
step :fetch_positions, priority: 1
|
|
16
|
+
step :exit_positions, priority: 2
|
|
17
|
+
|
|
18
|
+
def fetch_positions(ctx)
|
|
19
|
+
ctx[:positions] = DhanHQ::Models::Position.all.reject do |p|
|
|
20
|
+
qty = begin
|
|
21
|
+
p[:net_quantity] || p["netQuantity"] || p.net_quantity
|
|
22
|
+
rescue StandardError
|
|
23
|
+
0
|
|
24
|
+
end
|
|
25
|
+
qty.to_i.zero?
|
|
26
|
+
end
|
|
27
|
+
ctx
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def exit_positions(ctx)
|
|
31
|
+
results = ctx[:positions].map do
|
|
32
|
+
DhanHQ::Models::Position.exit_all!
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
{ error: e.message }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ctx[:exit_results] = results
|
|
38
|
+
ctx[:exited_count] = results.count { |r| !(r.is_a?(Hash) && r.key?(:error)) }
|
|
39
|
+
ctx[:failed_count] = results.count { |r| r.is_a?(Hash) && r.key?(:error) }
|
|
40
|
+
ctx
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|