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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module MarketData
|
|
5
|
+
# A snapshot of option chain data for a single underlying.
|
|
6
|
+
#
|
|
7
|
+
# Wraps the raw OptionChain response into a convenient structure
|
|
8
|
+
# with typed accessors and helper methods for option analysis.
|
|
9
|
+
#
|
|
10
|
+
# @example Build a snapshot from OptionChain response
|
|
11
|
+
# response = DhanHQ::Models::OptionChain.fetch(
|
|
12
|
+
# underlying_scrip: 13,
|
|
13
|
+
# underlying_seg: "IDX_I",
|
|
14
|
+
# expiry: "2024-07-25"
|
|
15
|
+
# )
|
|
16
|
+
# snapshot = DhanHQ::MarketData::OptionSnapshot.from_response(response)
|
|
17
|
+
# snapshot.calls_at(24000) #=> Array of call option data
|
|
18
|
+
# snapshot.puts_at(24000) #=> Array of put option data
|
|
19
|
+
#
|
|
20
|
+
class OptionSnapshot
|
|
21
|
+
OptionLeg = Struct.new(:strike_price, :option_type, :ltp, :bid, :ask,
|
|
22
|
+
:volume, :open_interest, :implied_volatility,
|
|
23
|
+
:delta, :gamma, :theta, :vega) do
|
|
24
|
+
def call?
|
|
25
|
+
option_type == DhanHQ::Constants::OptionType::CALL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def put?
|
|
29
|
+
option_type == DhanHQ::Constants::OptionType::PUT
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def itm?(spot_price)
|
|
33
|
+
return false unless spot_price
|
|
34
|
+
|
|
35
|
+
call? ? strike_price < spot_price : strike_price > spot_price
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def otm?(spot_price)
|
|
39
|
+
return false unless spot_price
|
|
40
|
+
|
|
41
|
+
call? ? strike_price > spot_price : strike_price < spot_price
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def atm?(spot_price)
|
|
45
|
+
return false unless spot_price
|
|
46
|
+
|
|
47
|
+
(strike_price - spot_price).abs < 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :underlying_scrip, :underlying_seg, :expiry, :legs, :spot_price, :fetched_at
|
|
52
|
+
|
|
53
|
+
def initialize(legs = [], metadata = {})
|
|
54
|
+
@underlying_scrip = metadata[:underlying_scrip]
|
|
55
|
+
@underlying_seg = metadata[:underlying_seg]
|
|
56
|
+
@expiry = metadata[:expiry]
|
|
57
|
+
@spot_price = metadata[:spot_price]
|
|
58
|
+
@legs = legs
|
|
59
|
+
@fetched_at = Time.now
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Build an OptionSnapshot from a raw OptionChain API response.
|
|
63
|
+
def self.from_response(response)
|
|
64
|
+
data = response.is_a?(Hash) ? (response[:data] || response["data"] || response) : response
|
|
65
|
+
return new([], {}) unless data.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
legs = parse_legs(data)
|
|
68
|
+
metadata = {
|
|
69
|
+
underlying_scrip: data[:underlyingScrip] || data["underlyingScrip"],
|
|
70
|
+
underlying_seg: data[:underlyingSeg] || data["underlyingSeg"],
|
|
71
|
+
expiry: data[:expiry] || data["expiry"],
|
|
72
|
+
spot_price: data[:spot] || data["spot"]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
new(legs, metadata)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get all call option legs.
|
|
79
|
+
def calls
|
|
80
|
+
@legs.select(&:call?)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get all put option legs.
|
|
84
|
+
def puts
|
|
85
|
+
@legs.select(&:put?)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get all unique strike prices.
|
|
89
|
+
def strikes
|
|
90
|
+
@legs.map(&:strike_price).uniq.sort
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get option legs at a specific strike price.
|
|
94
|
+
def at_strike(strike_price)
|
|
95
|
+
@legs.select { |leg| leg.strike_price == strike_price }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get call option at a specific strike price.
|
|
99
|
+
def call_at(strike_price)
|
|
100
|
+
calls.find { |leg| leg.strike_price == strike_price }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get put option at a specific strike price.
|
|
104
|
+
def put_at(strike_price)
|
|
105
|
+
puts.find { |leg| leg.strike_price == strike_price }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get all ITM calls (calls with strike below spot).
|
|
109
|
+
def itm_calls
|
|
110
|
+
return [] unless @spot_price
|
|
111
|
+
|
|
112
|
+
calls.select { |leg| leg.itm?(@spot_price) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get all OTM calls (calls with strike above spot).
|
|
116
|
+
def otm_calls
|
|
117
|
+
return [] unless @spot_price
|
|
118
|
+
|
|
119
|
+
calls.select { |leg| leg.otm?(@spot_price) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get all ITM puts (puts with strike above spot).
|
|
123
|
+
def itm_puts
|
|
124
|
+
return [] unless @spot_price
|
|
125
|
+
|
|
126
|
+
puts.select { |leg| leg.itm?(@spot_price) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get all OTM puts (puts with strike below spot).
|
|
130
|
+
def otm_puts
|
|
131
|
+
return [] unless @spot_price
|
|
132
|
+
|
|
133
|
+
puts.select { |leg| leg.otm?(@spot_price) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Find the ATM strike (closest to spot price).
|
|
137
|
+
def atm_strike
|
|
138
|
+
return nil unless @spot_price || strikes.any?
|
|
139
|
+
|
|
140
|
+
spot = @spot_price || strikes.first
|
|
141
|
+
strikes.min_by { |strike| (strike - spot).abs }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Get the total open interest across all legs.
|
|
145
|
+
def total_oi
|
|
146
|
+
@legs.sum { |leg| leg.open_interest.to_i }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get the total volume across all legs.
|
|
150
|
+
def total_volume
|
|
151
|
+
@legs.sum { |leg| leg.volume.to_i }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Calculate put-call ratio by open interest.
|
|
155
|
+
def pcr_by_oi
|
|
156
|
+
call_oi = calls.sum { |leg| leg.open_interest.to_i }
|
|
157
|
+
put_oi = puts.sum { |leg| leg.open_interest.to_i }
|
|
158
|
+
return 0.0 if call_oi.zero?
|
|
159
|
+
|
|
160
|
+
put_oi.to_f / call_oi
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Calculate put-call ratio by volume.
|
|
164
|
+
def pcr_by_volume
|
|
165
|
+
call_vol = calls.sum { |leg| leg.volume.to_i }
|
|
166
|
+
put_vol = puts.sum { |leg| leg.volume.to_i }
|
|
167
|
+
return 0.0 if call_vol.zero?
|
|
168
|
+
|
|
169
|
+
put_vol.to_f / call_vol
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.parse_legs(data)
|
|
173
|
+
# Parse from the option chain structure
|
|
174
|
+
ce_data = data[:ce] || data["ce"] || {}
|
|
175
|
+
pe_data = data[:pe] || data["pe"] || {}
|
|
176
|
+
|
|
177
|
+
ce_strikes = ce_data[:strike] || ce_data["strike"] || []
|
|
178
|
+
pe_strikes = pe_data[:strike] || pe_data["strike"] || []
|
|
179
|
+
|
|
180
|
+
# Parse CE (call) and PE (put) legs
|
|
181
|
+
parse_option_legs(ce_data, DhanHQ::Constants::OptionType::CALL, ce_strikes) +
|
|
182
|
+
parse_option_legs(pe_data, DhanHQ::Constants::OptionType::PUT, pe_strikes)
|
|
183
|
+
end
|
|
184
|
+
private_class_method :parse_legs
|
|
185
|
+
|
|
186
|
+
def self.parse_option_legs(data, option_type, strikes)
|
|
187
|
+
return [] unless data.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
strikes.each_with_index.map do |strike, i|
|
|
190
|
+
build_leg(data, option_type, strike, i)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_class_method :parse_option_legs
|
|
194
|
+
|
|
195
|
+
def self.build_leg(data, option_type, strike, index)
|
|
196
|
+
OptionLeg.new(
|
|
197
|
+
strike_price: strike.to_f,
|
|
198
|
+
option_type: option_type,
|
|
199
|
+
ltp: extract_value(data, :ltp, index),
|
|
200
|
+
bid: extract_value(data, :bid, index),
|
|
201
|
+
ask: extract_value(data, :ask, index),
|
|
202
|
+
volume: extract_value(data, :volume, index, as_integer: true),
|
|
203
|
+
open_interest: extract_value(data, :oi, index, as_integer: true),
|
|
204
|
+
implied_volatility: extract_value(data, :iv, index),
|
|
205
|
+
delta: extract_value(data, :delta, index),
|
|
206
|
+
gamma: extract_value(data, :gamma, index),
|
|
207
|
+
theta: extract_value(data, :theta, index),
|
|
208
|
+
vega: extract_value(data, :vega, index)
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
private_class_method :build_leg
|
|
212
|
+
|
|
213
|
+
def self.extract_value(data, key, index, as_integer: false)
|
|
214
|
+
values = data[key] || data[key.to_s] || []
|
|
215
|
+
value = values[index]
|
|
216
|
+
return nil if value.nil?
|
|
217
|
+
|
|
218
|
+
as_integer ? value.to_i : value.to_f
|
|
219
|
+
end
|
|
220
|
+
private_class_method :extract_value
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "market_data/market_snapshot"
|
|
4
|
+
require_relative "market_data/ohlc_series"
|
|
5
|
+
require_relative "market_data/option_snapshot"
|
|
6
|
+
|
|
7
|
+
module DhanHQ
|
|
8
|
+
# Higher-level abstractions for market data consumption.
|
|
9
|
+
#
|
|
10
|
+
# Provides typed wrappers around raw API responses for more convenient
|
|
11
|
+
# market data analysis and consumption.
|
|
12
|
+
#
|
|
13
|
+
# @example Quick market snapshot
|
|
14
|
+
# response = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [11536])
|
|
15
|
+
# snapshot = DhanHQ::MarketData::MarketSnapshot.from_response(response)
|
|
16
|
+
# puts snapshot.ltp("NSE_EQ", "11536")
|
|
17
|
+
#
|
|
18
|
+
# @example Historical OHLC series
|
|
19
|
+
# response = DhanHQ::Models::HistoricalData.daily(...)
|
|
20
|
+
# series = DhanHQ::MarketData::OHLCSeries.from_response(response)
|
|
21
|
+
# puts series.average_close
|
|
22
|
+
#
|
|
23
|
+
module MarketData
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../agent"
|
|
5
|
+
|
|
6
|
+
module DhanHQ
|
|
7
|
+
module MCP
|
|
8
|
+
# Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
|
|
9
|
+
class Server
|
|
10
|
+
def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env)
|
|
11
|
+
@input = input
|
|
12
|
+
@output = output
|
|
13
|
+
@policy = policy
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
@input.each_line { |line| handle_line(line) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle_line(line)
|
|
21
|
+
request = JSON.parse(line)
|
|
22
|
+
respond(request["id"], dispatch(request["method"], request["params"] || {}))
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
respond(nil, nil, code: -32_000, message: e.message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def dispatch(method, params)
|
|
30
|
+
case method
|
|
31
|
+
when "initialize"
|
|
32
|
+
{
|
|
33
|
+
protocolVersion: "2024-11-05",
|
|
34
|
+
serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
|
|
35
|
+
capabilities: { tools: {} }
|
|
36
|
+
}
|
|
37
|
+
when "tools/list"
|
|
38
|
+
{ tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
|
|
39
|
+
when "tools/call"
|
|
40
|
+
result = DhanHQ::Agent::ToolRegistry.execute(
|
|
41
|
+
params.fetch("name"),
|
|
42
|
+
params.fetch("arguments", {}),
|
|
43
|
+
policy: @policy
|
|
44
|
+
)
|
|
45
|
+
{ content: [{ type: "text", text: JSON.pretty_generate(serialize(result)) }] }
|
|
46
|
+
else
|
|
47
|
+
raise ArgumentError, "Unsupported MCP method: #{method}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mcp_tool(tool)
|
|
52
|
+
{ name: tool[:name], description: "[#{tool[:risk]}] #{tool[:description]}", inputSchema: tool[:input_schema] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def serialize(value)
|
|
56
|
+
case value
|
|
57
|
+
when Array then value.map { |v| serialize(v) }
|
|
58
|
+
when Hash then value.transform_values { |v| serialize(v) }
|
|
59
|
+
else
|
|
60
|
+
value.respond_to?(:attributes) ? value.attributes : value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def respond(id, result, code: nil, message: nil)
|
|
65
|
+
payload = { jsonrpc: "2.0", id: id }
|
|
66
|
+
code ? payload[:error] = { code: code, message: message } : payload[:result] = result
|
|
67
|
+
@output.puts(JSON.generate(payload))
|
|
68
|
+
@output.flush
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/DhanHQ/mcp.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mcp/server"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
# MCP namespace provides Model Context Protocol server for AI agent integration.
|
|
7
|
+
# Enables stdio-based JSON-RPC communication with AI tools and agents.
|
|
8
|
+
module MCP
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/DhanHQ/models/funds.rb
CHANGED
|
@@ -29,6 +29,18 @@ module DhanHQ
|
|
|
29
29
|
attributes :available_balance, :sod_limit, :collateral_amount, :receiveable_amount, :utilized_amount,
|
|
30
30
|
:blocked_payout_amount, :withdrawable_balance
|
|
31
31
|
|
|
32
|
+
# Returns a concise prompt-friendly summary of funds.
|
|
33
|
+
def to_prompt
|
|
34
|
+
parts = []
|
|
35
|
+
parts << "available=₹#{available_balance}" if available_balance
|
|
36
|
+
parts << "utilized=₹#{utilized_amount}" if utilized_amount
|
|
37
|
+
parts << "withdrawable=₹#{withdrawable_balance}" if withdrawable_balance
|
|
38
|
+
parts << "collateral=₹#{collateral_amount}" if collateral_amount&.positive?
|
|
39
|
+
parts << "sod_limit=₹#{sod_limit}" if sod_limit
|
|
40
|
+
parts << "blocked_payout=₹#{blocked_payout_amount}" if blocked_payout_amount&.positive?
|
|
41
|
+
parts.join(", ")
|
|
42
|
+
end
|
|
43
|
+
|
|
32
44
|
##
|
|
33
45
|
# Normalizes the typo'd `availabelBalance` key from the API response to `available_balance`.
|
|
34
46
|
#
|
|
@@ -33,6 +33,48 @@ module DhanHQ
|
|
|
33
33
|
attributes :exchange, :trading_symbol, :security_id, :isin, :total_qty,
|
|
34
34
|
:dp_qty, :t1_qty, :available_qty, :collateral_qty, :avg_cost_price
|
|
35
35
|
|
|
36
|
+
# Returns a concise prompt-friendly summary of the holding.
|
|
37
|
+
def to_prompt
|
|
38
|
+
parts = [
|
|
39
|
+
"#{total_qty}x #{trading_symbol || security_id}",
|
|
40
|
+
"on #{exchange}"
|
|
41
|
+
]
|
|
42
|
+
parts << "avg_cost=#{avg_cost_price}" if avg_cost_price&.positive?
|
|
43
|
+
parts << "available=#{available_qty}" if available_qty
|
|
44
|
+
parts << "dp=#{dp_qty}" if dp_qty&.positive?
|
|
45
|
+
parts << "t1=#{t1_qty}" if t1_qty&.positive?
|
|
46
|
+
parts << "collateral=#{collateral_qty}" if collateral_qty&.positive?
|
|
47
|
+
parts << "isin=#{isin}" if isin
|
|
48
|
+
parts.join(", ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns true if this holding has pending T1 delivery.
|
|
52
|
+
def pending_delivery?
|
|
53
|
+
t1_qty.to_i.positive?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns true if this holding is fully delivered (no T1).
|
|
57
|
+
def delivered?
|
|
58
|
+
t1_qty.to_i.zero?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns true if any quantity is pledged as collateral.
|
|
62
|
+
def pledged?
|
|
63
|
+
collateral_qty.to_i.positive?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the percentage of holding that is pledged.
|
|
67
|
+
def pledge_percentage
|
|
68
|
+
return 0.0 if total_qty.to_i.zero?
|
|
69
|
+
|
|
70
|
+
(collateral_qty.to_i.to_f / total_qty.to_i * 100).round(2)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns true if this holding is partially pledged.
|
|
74
|
+
def partially_pledged?
|
|
75
|
+
pledged? && collateral_qty.to_i < total_qty.to_i
|
|
76
|
+
end
|
|
77
|
+
|
|
36
78
|
class << self
|
|
37
79
|
##
|
|
38
80
|
# Provides a shared instance of the Holdings resource.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../contracts/iceberg_order_contract"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Models
|
|
7
|
+
##
|
|
8
|
+
# Model for creating and managing Iceberg Orders.
|
|
9
|
+
#
|
|
10
|
+
# Iceberg orders allow you to place a large order while revealing only a small
|
|
11
|
+
# "visible" portion (disclosed quantity) to the market at a time. The remaining
|
|
12
|
+
# quantity is hidden and filled as prior legs execute, reducing market impact
|
|
13
|
+
# and information leakage.
|
|
14
|
+
#
|
|
15
|
+
# @note **Static IP Whitelisting**: Iceberg order creation, modification, and
|
|
16
|
+
# cancellation APIs require Static IP whitelisting.
|
|
17
|
+
#
|
|
18
|
+
# @example Create an iceberg order
|
|
19
|
+
# order = DhanHQ::Models::IcebergOrder.create(
|
|
20
|
+
# dhan_client_id: "1000000003",
|
|
21
|
+
# transaction_type: "BUY",
|
|
22
|
+
# exchange_segment: "NSE_EQ",
|
|
23
|
+
# product_type: "INTRADAY",
|
|
24
|
+
# order_type: "LIMIT",
|
|
25
|
+
# validity: "DAY",
|
|
26
|
+
# security_id: "11536",
|
|
27
|
+
# quantity: 1000,
|
|
28
|
+
# price: 2450.50,
|
|
29
|
+
# iceberg_qty: 100,
|
|
30
|
+
# disclosed_quantity: 100
|
|
31
|
+
# )
|
|
32
|
+
# puts "Iceberg Order ID: #{order.order_id} - #{order.order_status}"
|
|
33
|
+
#
|
|
34
|
+
# @example Modify an iceberg order (change visible leg size)
|
|
35
|
+
# order = DhanHQ::Models::IcebergOrder.find(order_id)
|
|
36
|
+
# order.modify(
|
|
37
|
+
# dhan_client_id: "1000000003",
|
|
38
|
+
# order_id: order_id,
|
|
39
|
+
# price: 2440.0,
|
|
40
|
+
# iceberg_qty: 150
|
|
41
|
+
# )
|
|
42
|
+
#
|
|
43
|
+
# @example Cancel an iceberg order
|
|
44
|
+
# order = DhanHQ::Models::IcebergOrder.find(order_id)
|
|
45
|
+
# order.cancel
|
|
46
|
+
#
|
|
47
|
+
# @example List all iceberg orders for the day
|
|
48
|
+
# orders = DhanHQ::Models::IcebergOrder.all
|
|
49
|
+
#
|
|
50
|
+
class IcebergOrder < BaseModel
|
|
51
|
+
include Concerns::ApiResponseHandler
|
|
52
|
+
|
|
53
|
+
attributes :dhan_client_id, :order_id, :correlation_id, :order_status,
|
|
54
|
+
:transaction_type, :exchange_segment, :product_type, :order_type,
|
|
55
|
+
:validity, :trading_symbol, :security_id, :quantity,
|
|
56
|
+
:remaining_quantity, :price, :trigger_price, :after_market_order,
|
|
57
|
+
:iceberg_qty, :disclosed_quantity, :leg_name,
|
|
58
|
+
:create_time, :update_time, :exchange_time, :drv_expiry_date,
|
|
59
|
+
:drv_option_type, :drv_strike_price,
|
|
60
|
+
:oms_error_code, :oms_error_description, :average_traded_price, :filled_qty
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# @return [DhanHQ::Resources::IcebergOrders]
|
|
64
|
+
def resource
|
|
65
|
+
@resource ||= DhanHQ::Resources::IcebergOrders.new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Retrieves all iceberg orders for the day.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<IcebergOrder>]
|
|
71
|
+
def all
|
|
72
|
+
response = resource.all
|
|
73
|
+
return [] unless response.is_a?(Array)
|
|
74
|
+
|
|
75
|
+
response.map { |o| new(o, skip_validation: true) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Retrieves a specific iceberg order by ID.
|
|
79
|
+
#
|
|
80
|
+
# @param order_id [String]
|
|
81
|
+
# @return [IcebergOrder, nil]
|
|
82
|
+
def find(order_id)
|
|
83
|
+
response = resource.find(order_id)
|
|
84
|
+
return nil unless response.is_a?(Hash) && response.any?
|
|
85
|
+
|
|
86
|
+
new(response, skip_validation: true)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Creates a new iceberg order.
|
|
90
|
+
#
|
|
91
|
+
# @param params [Hash{Symbol => String, Integer, Float}]
|
|
92
|
+
# @return [IcebergOrder, nil]
|
|
93
|
+
def create(params)
|
|
94
|
+
normalized = snake_case(params)
|
|
95
|
+
config = DhanHQ.configuration
|
|
96
|
+
normalized[:dhan_client_id] ||= config.client_id if config&.client_id
|
|
97
|
+
validate_params!(normalized, DhanHQ::Contracts::IcebergOrderCreateContract)
|
|
98
|
+
formatted = camelize_keys(normalized)
|
|
99
|
+
response = resource.create(formatted)
|
|
100
|
+
return nil unless response.is_a?(Hash) && response["orderId"]
|
|
101
|
+
|
|
102
|
+
new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Modifies an existing iceberg order.
|
|
107
|
+
#
|
|
108
|
+
# @param new_params [Hash{Symbol => String, Integer, Float}]
|
|
109
|
+
# @return [IcebergOrder, nil]
|
|
110
|
+
def modify(new_params)
|
|
111
|
+
raise "Order ID is required to modify an iceberg order" unless order_id
|
|
112
|
+
|
|
113
|
+
DhanHQ.logger&.info("[DhanHQ::Models::IcebergOrder] Modifying order #{order_id}")
|
|
114
|
+
full_params = snake_case(new_params)
|
|
115
|
+
config = DhanHQ.configuration
|
|
116
|
+
full_params[:dhan_client_id] ||= config.client_id if config&.client_id
|
|
117
|
+
full_params[:order_id] = order_id
|
|
118
|
+
validate_params!(full_params, DhanHQ::Contracts::IcebergOrderModifyContract)
|
|
119
|
+
formatted = camelize_keys(full_params)
|
|
120
|
+
response = self.class.resource.update(order_id, formatted)
|
|
121
|
+
success = handle_api_response(response, success_key: "orderId",
|
|
122
|
+
context: "[DhanHQ::Models::IcebergOrder] Modification")
|
|
123
|
+
return self.class.find(order_id) if success
|
|
124
|
+
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Cancels the iceberg order.
|
|
129
|
+
#
|
|
130
|
+
# @return [Boolean] true when cancelled successfully
|
|
131
|
+
def cancel
|
|
132
|
+
raise "Order ID is required to cancel an iceberg order" unless order_id
|
|
133
|
+
|
|
134
|
+
response = self.class.resource.cancel(order_id)
|
|
135
|
+
response.is_a?(Hash) && response["orderStatus"] == DhanHQ::Constants::OrderStatus::CANCELLED
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../contracts/instrument_list_contract"
|
|
4
4
|
require_relative "instrument_helpers"
|
|
5
|
+
require_relative "search_result"
|
|
5
6
|
|
|
6
7
|
module DhanHQ
|
|
7
8
|
module Models
|
|
@@ -114,6 +115,41 @@ module DhanHQ
|
|
|
114
115
|
nil
|
|
115
116
|
end
|
|
116
117
|
|
|
118
|
+
# Search instruments across segments and return agent-friendly search results.
|
|
119
|
+
# @param query [String] symbol/company/index text
|
|
120
|
+
# @param options [Hash] :segments, :limit, :exact_match, :case_sensitive
|
|
121
|
+
# @return [Array<DhanHQ::Models::SearchResult>]
|
|
122
|
+
def search(query, options = {})
|
|
123
|
+
raise ArgumentError, "query is required" if query.to_s.strip.empty?
|
|
124
|
+
|
|
125
|
+
limit = Integer(options.fetch(:limit, 20))
|
|
126
|
+
segments = options[:segments] || %w[NSE_EQ BSE_EQ IDX_I NSE_FNO BSE_FNO NSE_CURRENCY BSE_CURRENCY MCX_COMM]
|
|
127
|
+
exact_match = options.fetch(:exact_match, false)
|
|
128
|
+
case_sensitive = options.fetch(:case_sensitive, false)
|
|
129
|
+
needle = case_sensitive ? query.to_s : query.to_s.upcase
|
|
130
|
+
|
|
131
|
+
results = []
|
|
132
|
+
segments.each do |segment|
|
|
133
|
+
by_segment(segment).each do |instrument|
|
|
134
|
+
haystacks = [
|
|
135
|
+
instrument.symbol_name,
|
|
136
|
+
instrument.display_name,
|
|
137
|
+
instrument.underlying_symbol,
|
|
138
|
+
instrument.isin
|
|
139
|
+
].compact
|
|
140
|
+
matched = haystacks.any? do |value|
|
|
141
|
+
comparable = case_sensitive ? value.to_s : value.to_s.upcase
|
|
142
|
+
exact_match ? comparable == needle : comparable.include?(needle)
|
|
143
|
+
end
|
|
144
|
+
next unless matched
|
|
145
|
+
|
|
146
|
+
results << DhanHQ::Models::SearchResult.new(instrument.attributes, skip_validation: true)
|
|
147
|
+
return results if results.length >= limit
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
results
|
|
151
|
+
end
|
|
152
|
+
|
|
117
153
|
def normalize_csv_row(row)
|
|
118
154
|
# Extract exchange and segment from CSV
|
|
119
155
|
exchange_id = row["EXCH_ID"] || row["EXCHANGE"]
|
data/lib/DhanHQ/models/order.rb
CHANGED
|
@@ -73,6 +73,101 @@ module DhanHQ
|
|
|
73
73
|
:drv_strike_price, :oms_error_code, :oms_error_description, :algo_id,
|
|
74
74
|
:remaining_quantity, :average_traded_price, :filled_qty
|
|
75
75
|
|
|
76
|
+
# Returns a concise prompt-friendly summary of the order.
|
|
77
|
+
# Useful for AI agents to understand order state without raw attributes.
|
|
78
|
+
def to_prompt
|
|
79
|
+
parts = [
|
|
80
|
+
"#{transaction_type} #{quantity}x #{trading_symbol || security_id}",
|
|
81
|
+
"on #{exchange_segment}",
|
|
82
|
+
"as #{order_type}/#{product_type}",
|
|
83
|
+
"status=#{order_status}"
|
|
84
|
+
]
|
|
85
|
+
parts << "price=#{price}" if price&.to_f&.positive?
|
|
86
|
+
parts << "trigger=#{trigger_price}" if trigger_price&.to_f&.positive?
|
|
87
|
+
parts << "filled=#{filled_qty}" if filled_qty&.positive?
|
|
88
|
+
parts << "remaining=#{remaining_quantity}" if remaining_quantity&.positive?
|
|
89
|
+
parts << "id=#{order_id}" if order_id
|
|
90
|
+
parts << "correlation=#{correlation_id}" if correlation_id
|
|
91
|
+
parts.join(", ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns true if the order has been fully filled.
|
|
95
|
+
def filled?
|
|
96
|
+
order_status == DhanHQ::Constants::OrderStatus::TRADED
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns true if the order is pending execution.
|
|
100
|
+
def pending?
|
|
101
|
+
order_status == DhanHQ::Constants::OrderStatus::PENDING
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns true if the order was rejected by the exchange.
|
|
105
|
+
def rejected?
|
|
106
|
+
order_status == DhanHQ::Constants::OrderStatus::REJECTED
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns true if the order was cancelled.
|
|
110
|
+
def cancelled?
|
|
111
|
+
order_status == DhanHQ::Constants::OrderStatus::CANCELLED
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Returns true if the order is in transit to the exchange.
|
|
115
|
+
def transit?
|
|
116
|
+
order_status == DhanHQ::Constants::OrderStatus::TRANSIT
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns true if the order is partially filled.
|
|
120
|
+
def partially_filled?
|
|
121
|
+
filled_qty.to_i.positive? && remaining_quantity.to_i.positive?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns the fill percentage (0.0 to 100.0).
|
|
125
|
+
def fill_percentage
|
|
126
|
+
return 0.0 if quantity.to_i.zero?
|
|
127
|
+
|
|
128
|
+
(filled_qty.to_i.to_f / quantity.to_i * 100).round(2)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Returns true if this is a buy order.
|
|
132
|
+
def buy?
|
|
133
|
+
transaction_type == DhanHQ::Constants::TransactionType::BUY
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns true if this is a sell order.
|
|
137
|
+
def sell?
|
|
138
|
+
transaction_type == DhanHQ::Constants::TransactionType::SELL
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns true if this is an intraday order.
|
|
142
|
+
def intraday?
|
|
143
|
+
product_type == DhanHQ::Constants::ProductType::INTRADAY
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns true if this is a delivery (CNC) order.
|
|
147
|
+
def delivery?
|
|
148
|
+
product_type == DhanHQ::Constants::ProductType::CNC
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns true if this is an MTF order.
|
|
152
|
+
def mtf?
|
|
153
|
+
product_type == DhanHQ::Constants::ProductType::MTF
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns true if this is a market order.
|
|
157
|
+
def market_order?
|
|
158
|
+
order_type == DhanHQ::Constants::OrderType::MARKET
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns true if this is a limit order.
|
|
162
|
+
def limit_order?
|
|
163
|
+
order_type == DhanHQ::Constants::OrderType::LIMIT
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Returns true if this is a stop-loss order.
|
|
167
|
+
def stop_loss_order?
|
|
168
|
+
order_type&.start_with?(DhanHQ::Constants::OrderType::STOP_LOSS)
|
|
169
|
+
end
|
|
170
|
+
|
|
76
171
|
class << self
|
|
77
172
|
##
|
|
78
173
|
# Provides a shared instance of the Orders resource.
|