DhanHQ 3.0.1 → 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/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/tool_registry.rb +51 -2
- data/lib/DhanHQ/concerns/order_audit.rb +43 -1
- data/lib/DhanHQ/constants.rb +3 -2
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
- data/lib/DhanHQ/mcp/server.rb +172 -9
- data/lib/DhanHQ/models/instrument.rb +44 -14
- 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 +1 -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 +1 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/pipeline.rb +8 -1
- data/lib/DhanHQ/skills/base.rb +54 -3
- 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 +10 -13
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
- 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 +5 -8
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/dhan_hq.rb +47 -0
- data/skills/dhanhq-ruby/SKILL.md +174 -41
- 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 +200 -6
- 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 +39 -3
- data/skills/dhanhq-ruby/references/market_data.md +0 -3
data/lib/DhanHQ/mcp/server.rb
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "timeout"
|
|
4
5
|
require_relative "../agent"
|
|
6
|
+
require_relative "../ai"
|
|
5
7
|
|
|
6
8
|
module DhanHQ
|
|
7
9
|
module MCP
|
|
8
10
|
# Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
|
|
9
11
|
class Server
|
|
10
|
-
|
|
12
|
+
# Raised for an unrecognized top-level JSON-RPC method (maps to -32601).
|
|
13
|
+
class UnknownMethodError < StandardError; end
|
|
14
|
+
# Raised for a well-formed request with invalid/unknown params (maps to -32602).
|
|
15
|
+
class InvalidParamsError < StandardError; end
|
|
16
|
+
|
|
17
|
+
SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05"].freeze
|
|
18
|
+
DEFAULT_TOOL_CALL_TIMEOUT_SECONDS = 15
|
|
19
|
+
|
|
20
|
+
RESOURCES = [
|
|
21
|
+
{ uri: "dhanhq://account/profile", name: "Dhan Profile", description: "Current account profile including client ID, PAN, name, and trading permissions",
|
|
22
|
+
mimeType: "application/json" },
|
|
23
|
+
{ uri: "dhanhq://account/funds", name: "Fund Limits", description: "Available balance, used margin, and withdrawal capacity", mimeType: "application/json" },
|
|
24
|
+
{ uri: "dhanhq://account/holdings", name: "Portfolio Holdings", description: "Current equity holdings with quantity, average price, and P&L", mimeType: "application/json" },
|
|
25
|
+
{ uri: "dhanhq://account/positions", name: "Open Positions", description: "Current F&O and equity positions with net quantity and unrealized P&L",
|
|
26
|
+
mimeType: "application/json" },
|
|
27
|
+
{ uri: "dhanhq://account/orders", name: "Recent Orders", description: "Recent order history with status, quantity, and fill details", mimeType: "application/json" },
|
|
28
|
+
{ uri: "dhanhq://market/capabilities", name: "Agent Capabilities", description: "All available tools, scopes, risk levels, and version info", mimeType: "application/json" }
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
PROMPTS = [
|
|
32
|
+
{ name: "portfolio_summary", description: "Generate a human-readable summary of your current portfolio, positions, and available funds" },
|
|
33
|
+
{ name: "market_analysis", description: "Analyze current market conditions and generate a trading context summary" },
|
|
34
|
+
{ name: "risk_report", description: "Review current risk exposure including open positions, P&L, and position limits" },
|
|
35
|
+
{ name: "suggest_strategy", description: "Suggest a trading strategy based on current portfolio and market conditions" },
|
|
36
|
+
{ name: "order_preview", description: "Preview an order before placing it with risk validation" }
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env,
|
|
40
|
+
tool_call_timeout: DEFAULT_TOOL_CALL_TIMEOUT_SECONDS)
|
|
11
41
|
@input = input
|
|
12
42
|
@output = output
|
|
13
43
|
@policy = policy
|
|
44
|
+
@tool_call_timeout = tool_call_timeout
|
|
14
45
|
end
|
|
15
46
|
|
|
16
47
|
def run
|
|
@@ -19,32 +50,164 @@ module DhanHQ
|
|
|
19
50
|
|
|
20
51
|
def handle_line(line)
|
|
21
52
|
request = JSON.parse(line)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
53
|
+
rescue JSON::ParserError => e
|
|
54
|
+
respond(nil, nil, code: -32_700, message: "Parse error: #{e.message}")
|
|
55
|
+
else
|
|
56
|
+
handle_request(request)
|
|
25
57
|
end
|
|
26
58
|
|
|
27
59
|
private
|
|
28
60
|
|
|
61
|
+
def handle_request(request)
|
|
62
|
+
return unless request.key?("id") # JSON-RPC notification — must not receive a response
|
|
63
|
+
|
|
64
|
+
id = request["id"]
|
|
65
|
+
respond(id, dispatch(request["method"], request["params"] || {}))
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
respond(id, nil, code: error_code_for(e), message: e.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def error_code_for(error)
|
|
71
|
+
case error
|
|
72
|
+
when UnknownMethodError then -32_601
|
|
73
|
+
when InvalidParamsError then -32_602
|
|
74
|
+
else -32_603
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
29
78
|
def dispatch(method, params)
|
|
30
79
|
case method
|
|
31
80
|
when "initialize"
|
|
32
81
|
{
|
|
33
|
-
protocolVersion: "
|
|
82
|
+
protocolVersion: negotiate_protocol_version(params["protocolVersion"]),
|
|
34
83
|
serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
|
|
35
|
-
capabilities: { tools: {} }
|
|
84
|
+
capabilities: { tools: {}, resources: {}, prompts: {} }
|
|
36
85
|
}
|
|
37
86
|
when "tools/list"
|
|
38
87
|
{ tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
|
|
39
88
|
when "tools/call"
|
|
40
|
-
|
|
89
|
+
{ content: [{ type: "text", text: JSON.pretty_generate(serialize(call_tool(params))) }] }
|
|
90
|
+
when "resources/list"
|
|
91
|
+
{ resources: resource_definitions }
|
|
92
|
+
when "resources/read"
|
|
93
|
+
resource = resources.find { |r| r[:uri] == params["uri"] }
|
|
94
|
+
raise InvalidParamsError, "Unknown resource: #{params["uri"]}" unless resource
|
|
95
|
+
|
|
96
|
+
{ contents: [resource_read(resource)] }
|
|
97
|
+
when "prompts/list"
|
|
98
|
+
{ prompts: prompt_definitions }
|
|
99
|
+
when "prompts/get"
|
|
100
|
+
prompt = prompts.find { |p| p[:name] == params["name"] }
|
|
101
|
+
raise InvalidParamsError, "Unknown prompt: #{params["name"]}" unless prompt
|
|
102
|
+
|
|
103
|
+
prompt_result(prompt, params.fetch("arguments", {}))
|
|
104
|
+
else
|
|
105
|
+
raise UnknownMethodError, "Unsupported MCP method: #{method}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def negotiate_protocol_version(requested)
|
|
110
|
+
SUPPORTED_PROTOCOL_VERSIONS.include?(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS.last
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def call_tool(params)
|
|
114
|
+
Timeout.timeout(@tool_call_timeout) do
|
|
115
|
+
DhanHQ::Agent::ToolRegistry.execute(
|
|
41
116
|
params.fetch("name"),
|
|
42
117
|
params.fetch("arguments", {}),
|
|
43
118
|
policy: @policy
|
|
44
119
|
)
|
|
45
|
-
|
|
120
|
+
end
|
|
121
|
+
rescue ArgumentError => e
|
|
122
|
+
raise InvalidParamsError, e.message
|
|
123
|
+
rescue Timeout::Error
|
|
124
|
+
raise "Tool call '#{params["name"]}' timed out after #{@tool_call_timeout}s " \
|
|
125
|
+
"(likely blocked on rate-limit backoff) — retry shortly"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resources
|
|
129
|
+
RESOURCES
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def prompts
|
|
133
|
+
PROMPTS
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def resource_definitions
|
|
137
|
+
RESOURCES.map { |r| r.except(:handler) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resource_read(resource)
|
|
141
|
+
handler = resource_handler(resource[:uri])
|
|
142
|
+
data = handler.call
|
|
143
|
+
{ uri: resource[:uri], mimeType: "application/json", text: JSON.pretty_generate(serialize(data)) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resource_handler(uri)
|
|
147
|
+
case uri
|
|
148
|
+
when "dhanhq://account/profile" then -> { DhanHQ::Models::Profile.fetch }
|
|
149
|
+
when "dhanhq://account/funds" then -> { DhanHQ::Models::Funds.fetch }
|
|
150
|
+
when "dhanhq://account/holdings" then -> { DhanHQ::Models::Holding.all }
|
|
151
|
+
when "dhanhq://account/positions" then -> { DhanHQ::Models::Position.all }
|
|
152
|
+
when "dhanhq://account/orders" then -> { DhanHQ::Models::Order.all }
|
|
153
|
+
when "dhanhq://market/capabilities" then -> { DhanHQ::Agent::ToolRegistry.capabilities }
|
|
154
|
+
else raise ArgumentError, "No handler for resource: #{uri}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def prompt_definitions
|
|
159
|
+
PROMPTS.map { |p| { name: p[:name], description: p[:description], arguments: prompt_arguments(p[:name]) } }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def prompt_arguments(prompt_name)
|
|
163
|
+
case prompt_name
|
|
164
|
+
when "order_preview"
|
|
165
|
+
[{ name: "transaction_type", description: "BUY or SELL", required: true },
|
|
166
|
+
{ name: "security_id", description: "Dhan security ID", required: true },
|
|
167
|
+
{ name: "quantity", description: "Number of shares/lots", required: true },
|
|
168
|
+
{ name: "exchange_segment", description: "NSE_EQ, NSE_FNO, etc.", required: true },
|
|
169
|
+
{ name: "price", description: "Limit price (optional for MARKET)", required: false }]
|
|
170
|
+
when "market_analysis"
|
|
171
|
+
[{ name: "symbol", description: "Ticker symbol (e.g., NIFTY, RELIANCE)", required: false }]
|
|
172
|
+
else
|
|
173
|
+
[]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def prompt_result(prompt, arguments)
|
|
178
|
+
case prompt[:name]
|
|
179
|
+
when "portfolio_summary"
|
|
180
|
+
holdings = DhanHQ::Models::Holding.all
|
|
181
|
+
positions = DhanHQ::Models::Position.all
|
|
182
|
+
funds = DhanHQ::Models::Funds.fetch
|
|
183
|
+
summary = DhanHQ::AI::PromptHelpers.portfolio_summary(holdings: holdings, positions: positions, funds: funds)
|
|
184
|
+
{ messages: [{ role: "user", content: { type: "text", text: summary } }] }
|
|
185
|
+
when "market_analysis"
|
|
186
|
+
symbol = arguments["symbol"] || "NIFTY"
|
|
187
|
+
instrument = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, symbol)
|
|
188
|
+
security_id = instrument&.security_id&.to_i
|
|
189
|
+
if security_id
|
|
190
|
+
snapshot = DhanHQ::Models::MarketFeed.quote(DhanHQ::Constants::ExchangeSegment::IDX_I => [security_id])
|
|
191
|
+
text = "Market snapshot for #{symbol}: #{snapshot}"
|
|
192
|
+
else
|
|
193
|
+
text = "Could not resolve symbol #{symbol} to a security ID for market data."
|
|
194
|
+
end
|
|
195
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
196
|
+
when "risk_report"
|
|
197
|
+
positions = DhanHQ::Models::Position.all
|
|
198
|
+
text = DhanHQ::AI::PromptHelpers.risk_report(positions: positions)
|
|
199
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
200
|
+
when "order_preview"
|
|
201
|
+
preview = DhanHQ::Agent::OrderPreview.new(arguments)
|
|
202
|
+
text = preview.valid? ? "Order preview: #{preview.to_h[:summary]}" : "Validation errors: #{preview.errors.join(", ")}"
|
|
203
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
204
|
+
when "suggest_strategy"
|
|
205
|
+
positions = DhanHQ::Models::Position.all
|
|
206
|
+
funds = DhanHQ::Models::Funds.fetch
|
|
207
|
+
text = "Current portfolio: #{positions.size} open positions, Available: ₹#{funds.available_balance}. Review positions for strategy suggestions."
|
|
208
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
46
209
|
else
|
|
47
|
-
raise ArgumentError, "
|
|
210
|
+
raise ArgumentError, "Unknown prompt: #{prompt[:name]}"
|
|
48
211
|
end
|
|
49
212
|
end
|
|
50
213
|
|
|
@@ -63,25 +63,55 @@ module DhanHQ
|
|
|
63
63
|
exact_match = options[:exact_match] || false
|
|
64
64
|
case_sensitive = options[:case_sensitive] || false
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
return nil
|
|
66
|
+
csv_text = resource.by_segment(exchange_segment)
|
|
67
|
+
return nil unless csv_text.is_a?(String) && !csv_text.empty?
|
|
68
68
|
|
|
69
|
+
require "csv"
|
|
69
70
|
search_symbol = case_sensitive ? symbol : symbol.upcase
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
# Scan raw CSV rows and instantiate only the match — building a full
|
|
73
|
+
# Instrument for every row in a large segment (NSE_EQ is ~219k rows)
|
|
74
|
+
# is minutes-slow and can use gigabytes of memory (see #by_segment).
|
|
75
|
+
row = CSV.parse(csv_text, headers: true).find do |r|
|
|
72
76
|
# For equity instruments, prefer underlying_symbol over symbol_name
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
instrument_symbol.include?(search_symbol)
|
|
83
|
-
end
|
|
77
|
+
row_symbol = if r["INSTRUMENT"] == DhanHQ::Constants::InstrumentType::EQUITY && !r["UNDERLYING_SYMBOL"].to_s.empty?
|
|
78
|
+
r["UNDERLYING_SYMBOL"]
|
|
79
|
+
else
|
|
80
|
+
r["SYMBOL_NAME"]
|
|
81
|
+
end
|
|
82
|
+
next false if row_symbol.nil?
|
|
83
|
+
|
|
84
|
+
comparable = case_sensitive ? row_symbol : row_symbol.upcase
|
|
85
|
+
exact_match ? comparable == search_symbol : comparable.include?(search_symbol)
|
|
84
86
|
end
|
|
87
|
+
|
|
88
|
+
return nil unless row
|
|
89
|
+
|
|
90
|
+
new(normalize_csv_row(row), skip_validation: true)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Find a specific instrument within a segment by its security ID.
|
|
94
|
+
# @param exchange_segment [String] The exchange segment (e.g., "NSE_EQ", "NSE_FNO")
|
|
95
|
+
# @param security_id [String, Integer] The Dhan security ID
|
|
96
|
+
# @return [Instrument, nil] The found instrument or nil if not found
|
|
97
|
+
# @example
|
|
98
|
+
# instrument = DhanHQ::Models::Instrument.find_by_security_id("NSE_EQ", "2885")
|
|
99
|
+
# puts instrument.symbol_name # => "RELIANCE"
|
|
100
|
+
def find_by_security_id(exchange_segment, security_id)
|
|
101
|
+
validate_params!({ exchange_segment: exchange_segment }, DhanHQ::Contracts::InstrumentListContract)
|
|
102
|
+
|
|
103
|
+
csv_text = resource.by_segment(exchange_segment)
|
|
104
|
+
return nil unless csv_text.is_a?(String) && !csv_text.empty?
|
|
105
|
+
|
|
106
|
+
require "csv"
|
|
107
|
+
# Scan raw CSV rows and instantiate only the match — building a full
|
|
108
|
+
# Instrument (and its per-attribute singleton methods) for every row
|
|
109
|
+
# in a large segment (NSE_EQ is ~219k rows) is minutes-slow and can
|
|
110
|
+
# use gigabytes of memory. See #by_segment for the bulk path.
|
|
111
|
+
row = CSV.parse(csv_text, headers: true).find { |r| r["SECURITY_ID"].to_s == security_id.to_s }
|
|
112
|
+
return nil unless row
|
|
113
|
+
|
|
114
|
+
new(normalize_csv_row(row), skip_validation: true)
|
|
85
115
|
end
|
|
86
116
|
|
|
87
117
|
# Find a specific instrument across all exchange segments.
|
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,
|
|
@@ -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,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,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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
module Checks
|
|
6
|
+
# Enforces maximum number of concurrent open positions.
|
|
7
|
+
class PositionLimits
|
|
8
|
+
MAX_OPEN_POSITIONS = 20
|
|
9
|
+
|
|
10
|
+
def self.run!(**_unused)
|
|
11
|
+
positions = DhanHQ::Models::Position.all
|
|
12
|
+
open_count = positions.count do |p|
|
|
13
|
+
p.net_qty.to_i != 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
return if open_count < MAX_OPEN_POSITIONS
|
|
17
|
+
|
|
18
|
+
raise DhanHQ::RiskViolation,
|
|
19
|
+
"Maximum #{MAX_OPEN_POSITIONS} open positions exceeded (#{open_count} open)"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/DhanHQ/risk/pipeline.rb
CHANGED
|
@@ -30,13 +30,19 @@ module DhanHQ
|
|
|
30
30
|
Checks::ProductSupport,
|
|
31
31
|
Checks::OrderType,
|
|
32
32
|
Checks::Quantity,
|
|
33
|
-
Checks::MarketHours
|
|
33
|
+
Checks::MarketHours,
|
|
34
|
+
Checks::PositionLimits,
|
|
35
|
+
Checks::Concentration
|
|
34
36
|
].freeze
|
|
35
37
|
|
|
36
38
|
OPTION_CHECKS = [
|
|
37
39
|
Checks::Options
|
|
38
40
|
].freeze
|
|
39
41
|
|
|
42
|
+
DAILY_CHECKS = [
|
|
43
|
+
Checks::MaxLoss
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
40
46
|
# Run all applicable risk checks.
|
|
41
47
|
#
|
|
42
48
|
# @param instrument [Object] instrument with trading metadata
|
|
@@ -49,6 +55,7 @@ module DhanHQ
|
|
|
49
55
|
def self.run!(instrument:, args:, now: Time.now, type: :equity)
|
|
50
56
|
run_checks!(CHECKS, instrument, args, now)
|
|
51
57
|
run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
|
|
58
|
+
run_checks!(DAILY_CHECKS, instrument, args, now)
|
|
52
59
|
true
|
|
53
60
|
end
|
|
54
61
|
# rubocop:enable Naming/PredicateMethod
|
data/lib/DhanHQ/skills/base.rb
CHANGED
|
@@ -23,7 +23,7 @@ module DhanHQ
|
|
|
23
23
|
# end
|
|
24
24
|
#
|
|
25
25
|
# def get_spot_price(ctx)
|
|
26
|
-
# ctx[:spot_price] = ctx[:instrument].ltp
|
|
26
|
+
# ctx[:spot_price] = ctx[:instrument].ltp
|
|
27
27
|
# ctx
|
|
28
28
|
# end
|
|
29
29
|
#
|
|
@@ -56,6 +56,28 @@ module DhanHQ
|
|
|
56
56
|
@steps.sort_by! { |s| s[:priority] }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# MCP risk level for this skill (defaults to the most conservative tier
|
|
60
|
+
# so a skill that forgets to declare one fails safe/write-gated).
|
|
61
|
+
#
|
|
62
|
+
# @param level [String, nil] one of read_only, trade_adjacent_read, live_write, destructive_write
|
|
63
|
+
def risk(level = nil)
|
|
64
|
+
level ? (@risk = level) : (@risk || "destructive_write")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# MCP policy scope required to invoke this skill.
|
|
68
|
+
#
|
|
69
|
+
# @param value [String, nil] e.g. "orders:read", "orders:write"
|
|
70
|
+
def scope(value = nil)
|
|
71
|
+
value ? (@scope = value) : (@scope || "orders:write")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Human-readable description shown to MCP/LLM clients in tools/list.
|
|
75
|
+
#
|
|
76
|
+
# @param text [String, nil] one-line summary of what the skill does
|
|
77
|
+
def description(text = nil)
|
|
78
|
+
text ? (@description = text) : @description
|
|
79
|
+
end
|
|
80
|
+
|
|
59
81
|
# Accessor for defined parameters.
|
|
60
82
|
def params
|
|
61
83
|
@params || {}
|
|
@@ -104,9 +126,9 @@ module DhanHQ
|
|
|
104
126
|
self.class.name || self.class.to_s
|
|
105
127
|
end
|
|
106
128
|
|
|
107
|
-
# Skill description (
|
|
129
|
+
# Skill description (declare via the class-level `description` macro; falls back to class name).
|
|
108
130
|
def description
|
|
109
|
-
self.class.to_s
|
|
131
|
+
self.class.description || self.class.to_s
|
|
110
132
|
end
|
|
111
133
|
|
|
112
134
|
# List of parameter definitions for this skill.
|
|
@@ -116,6 +138,35 @@ module DhanHQ
|
|
|
116
138
|
|
|
117
139
|
private
|
|
118
140
|
|
|
141
|
+
# Real DhanHQ::Models::OptionChain#fetch shape: { last_price:, strikes: [{ strike:, call: {...}, put: {...} }] }.
|
|
142
|
+
# Nearest strike to a target price — always returns an entry (never nil) unless the chain is empty.
|
|
143
|
+
def nearest_strike(chain, target_price)
|
|
144
|
+
strikes = chain[:strikes]
|
|
145
|
+
return nil if strikes.nil? || strikes.empty?
|
|
146
|
+
|
|
147
|
+
strikes.min_by { |s| (s[:strike].to_f - target_price.to_f).abs }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Exact strike match within tolerance — nil if no strike sits on that price.
|
|
151
|
+
def find_strike(chain, target_price, tolerance: 0.001)
|
|
152
|
+
strikes = chain[:strikes]
|
|
153
|
+
return nil if strikes.nil? || strikes.empty?
|
|
154
|
+
|
|
155
|
+
strikes.find { |s| (s[:strike].to_f - target_price.to_f).abs < tolerance }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def leg_side(strike_entry, option_type)
|
|
159
|
+
option_type == "CE" ? strike_entry[:call] : strike_entry[:put]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def leg_security_id(strike_entry, option_type)
|
|
163
|
+
leg_side(strike_entry, option_type)[:security_id]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def leg_premium(strike_entry, option_type)
|
|
167
|
+
leg_side(strike_entry, option_type)[:last_price]
|
|
168
|
+
end
|
|
169
|
+
|
|
119
170
|
def build_context(args)
|
|
120
171
|
ctx = {}
|
|
121
172
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build a bear call spread (sell OTM call, buy further OTM call).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → spot price → option chain →
|
|
9
|
+
# select strikes → build intent.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = DhanHQ::Skills::Registry.call("bear_call_spread",
|
|
13
|
+
# symbol: "NIFTY",
|
|
14
|
+
# expiry: "2026-01-30",
|
|
15
|
+
# quantity: 50
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class BearCallSpread < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build a bear call spread: sell an OTM call, buy a further OTM call for defined risk."
|
|
22
|
+
|
|
23
|
+
param :symbol, type: :string, required: true
|
|
24
|
+
param :expiry, type: :string, required: true
|
|
25
|
+
param :quantity, type: :integer, default: 50
|
|
26
|
+
param :spread_width, type: :number, default: 200
|
|
27
|
+
param :max_loss, type: :number, default: 5000
|
|
28
|
+
|
|
29
|
+
step :find_instrument, priority: 1
|
|
30
|
+
step :get_spot_price, priority: 2
|
|
31
|
+
step :get_option_chain, priority: 3
|
|
32
|
+
step :select_strikes, priority: 4
|
|
33
|
+
step :build_intent, priority: 5
|
|
34
|
+
|
|
35
|
+
def find_instrument(ctx)
|
|
36
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
|
|
37
|
+
ctx
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_spot_price(ctx)
|
|
41
|
+
ctx[:spot_price] = ctx[:instrument].ltp
|
|
42
|
+
ctx
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def get_option_chain(ctx)
|
|
46
|
+
ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
|
|
47
|
+
ctx
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def select_strikes(ctx)
|
|
51
|
+
spot = ctx[:spot_price].to_f
|
|
52
|
+
chain = ctx[:chain]
|
|
53
|
+
spread = ctx[:spread_width].to_f
|
|
54
|
+
|
|
55
|
+
atm_strike_price = nearest_strike(chain, spot)[:strike].to_f
|
|
56
|
+
|
|
57
|
+
short_call = find_strike(chain, atm_strike_price + spread)
|
|
58
|
+
long_call = find_strike(chain, atm_strike_price + (spread * 2))
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "Could not build bear call spread — insufficient strikes in chain" unless short_call && long_call
|
|
61
|
+
|
|
62
|
+
ctx[:legs] = [
|
|
63
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: short_call[:strike],
|
|
64
|
+
security_id: leg_security_id(short_call, "CE") },
|
|
65
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: long_call[:strike],
|
|
66
|
+
security_id: leg_security_id(long_call, "CE") }
|
|
67
|
+
]
|
|
68
|
+
ctx
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_intent(ctx)
|
|
72
|
+
ctx[:intent] = {
|
|
73
|
+
trade_type: "BEAR_CALL_SPREAD",
|
|
74
|
+
symbol: ctx[:symbol],
|
|
75
|
+
expiry: ctx[:expiry],
|
|
76
|
+
quantity: ctx[:quantity],
|
|
77
|
+
spread_width: ctx[:spread_width],
|
|
78
|
+
max_loss: ctx[:max_loss],
|
|
79
|
+
legs: ctx[:legs],
|
|
80
|
+
note: "Bear call spread prepared. Await human confirmation before execution."
|
|
81
|
+
}
|
|
82
|
+
ctx
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|