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,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module MarketData
|
|
5
|
+
# A time series of OHLCV candles for a single instrument.
|
|
6
|
+
#
|
|
7
|
+
# Wraps historical data responses into a convenient array-like structure
|
|
8
|
+
# with helper methods for analysis.
|
|
9
|
+
#
|
|
10
|
+
# @example Build a series from historical data response
|
|
11
|
+
# response = DhanHQ::Models::HistoricalData.daily(
|
|
12
|
+
# security_id: "11536",
|
|
13
|
+
# exchange_segment: "NSE_EQ",
|
|
14
|
+
# instrument: "EQUITY",
|
|
15
|
+
# from_date: "2024-01-01",
|
|
16
|
+
# to_date: "2024-12-31"
|
|
17
|
+
# )
|
|
18
|
+
# series = DhanHQ::MarketData::OHLCSeries.from_response(response)
|
|
19
|
+
# series.closes #=> [2800.0, 2810.5, ...]
|
|
20
|
+
# series.volumes #=> [123456, 234567, ...]
|
|
21
|
+
#
|
|
22
|
+
class OHLCSeries
|
|
23
|
+
include Enumerable
|
|
24
|
+
|
|
25
|
+
Candle = Struct.new(:timestamp, :open, :high, :low, :close, :volume, :open_interest) do
|
|
26
|
+
def body_size
|
|
27
|
+
(close - open).abs
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def upper_shadow
|
|
31
|
+
high - [open, close].max
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lower_shadow
|
|
35
|
+
[open, close].min - low
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def bullish?
|
|
39
|
+
close > open
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def bearish?
|
|
43
|
+
close < open
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def doji?
|
|
47
|
+
(close - open).abs < (high - low) * 0.1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :candles, :security_id, :exchange_segment
|
|
52
|
+
|
|
53
|
+
def initialize(candles = [], metadata = {})
|
|
54
|
+
@candles = candles
|
|
55
|
+
@security_id = metadata[:security_id]
|
|
56
|
+
@exchange_segment = metadata[:exchange_segment]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build an OHLCSeries from a raw historical data API response.
|
|
60
|
+
def self.from_response(response)
|
|
61
|
+
data = response.is_a?(Hash) ? (response[:data] || response["data"] || response) : response
|
|
62
|
+
data = [data] unless data.is_a?(Array)
|
|
63
|
+
|
|
64
|
+
candles = data.map do |candle|
|
|
65
|
+
Candle.new(
|
|
66
|
+
timestamp: candle[:timestamp] || candle["timestamp"],
|
|
67
|
+
open: (candle[:open] || candle["open"]).to_f,
|
|
68
|
+
high: (candle[:high] || candle["high"]).to_f,
|
|
69
|
+
low: (candle[:low] || candle["low"]).to_f,
|
|
70
|
+
close: (candle[:close] || candle["close"]).to_f,
|
|
71
|
+
volume: (candle[:volume] || candle["volume"]).to_i,
|
|
72
|
+
open_interest: candle[:open_interest] || candle["open_interest"]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
new(candles)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def each(&)
|
|
80
|
+
@candles.each(&)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def size
|
|
84
|
+
@candles.size
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def empty?
|
|
88
|
+
@candles.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get all close prices.
|
|
92
|
+
def closes
|
|
93
|
+
@candles.map(&:close)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get all open prices.
|
|
97
|
+
def opens
|
|
98
|
+
@candles.map(&:open)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get all high prices.
|
|
102
|
+
def highs
|
|
103
|
+
@candles.map(&:high)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get all low prices.
|
|
107
|
+
def lows
|
|
108
|
+
@candles.map(&:low)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get all volumes.
|
|
112
|
+
def volumes
|
|
113
|
+
@candles.map(&:volume)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get the most recent candle.
|
|
117
|
+
def last
|
|
118
|
+
@candles.last
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get the oldest candle.
|
|
122
|
+
def first
|
|
123
|
+
@candles.first
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get the date range of the series.
|
|
127
|
+
def date_range
|
|
128
|
+
return nil if empty?
|
|
129
|
+
|
|
130
|
+
[first.timestamp, last.timestamp]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Calculate the total volume across all candles.
|
|
134
|
+
def total_volume
|
|
135
|
+
@candles.sum(&:volume)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Calculate the average close price.
|
|
139
|
+
def average_close
|
|
140
|
+
return nil if empty?
|
|
141
|
+
|
|
142
|
+
closes.sum / size
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Calculate the price range (highest high - lowest low).
|
|
146
|
+
def price_range
|
|
147
|
+
return nil if empty?
|
|
148
|
+
|
|
149
|
+
highs.max - lows.min
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Slice the series by date range (requires timestamps).
|
|
153
|
+
def slice_range(from_timestamp, to_timestamp)
|
|
154
|
+
self.class.new(
|
|
155
|
+
@candles.select { |c| c.timestamp.between?(from_timestamp, to_timestamp) },
|
|
156
|
+
{ security_id: @security_id, exchange_segment: @exchange_segment }
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Take the last N candles.
|
|
161
|
+
def tail(count)
|
|
162
|
+
self.class.new(
|
|
163
|
+
@candles.last(count),
|
|
164
|
+
{ security_id: @security_id, exchange_segment: @exchange_segment }
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -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.
|