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,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Technical analysis indicators for market data analysis.
|
|
5
|
+
module Indicators
|
|
6
|
+
# Simple Moving Average (SMA)
|
|
7
|
+
#
|
|
8
|
+
# Calculates the average of a specified number of data points.
|
|
9
|
+
#
|
|
10
|
+
# @example Calculate 20-period SMA
|
|
11
|
+
# closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
|
|
12
|
+
# sma = DhanHQ::Indicators::SMA.calculate(closes, period: 5)
|
|
13
|
+
# #=> [102.2, 103.0, 103.8, 104.6, 105.4, 106.8, 108.2, 107.8]
|
|
14
|
+
#
|
|
15
|
+
class SMA
|
|
16
|
+
# Calculate SMA for the given data.
|
|
17
|
+
#
|
|
18
|
+
# @param data [Array<Numeric>] Input price data
|
|
19
|
+
# @param period [Integer] Number of periods (default: 20)
|
|
20
|
+
# @return [Array<Float>] SMA values (nil for insufficient data points)
|
|
21
|
+
def self.calculate(data, period: 20)
|
|
22
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
23
|
+
|
|
24
|
+
data.each_index.map do |i|
|
|
25
|
+
next nil if i < period - 1
|
|
26
|
+
|
|
27
|
+
window = data[(i - period + 1)..i]
|
|
28
|
+
window.sum.to_f / period
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Exponential Moving Average (EMA)
|
|
34
|
+
#
|
|
35
|
+
# Weighted average that gives more importance to recent prices.
|
|
36
|
+
#
|
|
37
|
+
# @example Calculate 12-period EMA
|
|
38
|
+
# closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
|
|
39
|
+
# ema = DhanHQ::Indicators::EMA.calculate(closes, period: 5)
|
|
40
|
+
#
|
|
41
|
+
class EMA
|
|
42
|
+
# Calculate EMA for the given data.
|
|
43
|
+
#
|
|
44
|
+
# @param data [Array<Numeric>] Input price data
|
|
45
|
+
# @param period [Integer] Number of periods (default: 20)
|
|
46
|
+
# @return [Array<Float>] EMA values (nil for insufficient data points)
|
|
47
|
+
def self.calculate(data, period: 20)
|
|
48
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
49
|
+
|
|
50
|
+
multiplier = 2.0 / (period + 1)
|
|
51
|
+
ema_values = []
|
|
52
|
+
|
|
53
|
+
data.each_with_index do |price, i|
|
|
54
|
+
if i < period - 1
|
|
55
|
+
ema_values << nil
|
|
56
|
+
elsif i == period - 1
|
|
57
|
+
# First EMA is SMA
|
|
58
|
+
window = data[0..i]
|
|
59
|
+
ema_values << (window.sum.to_f / period)
|
|
60
|
+
else
|
|
61
|
+
# EMA = (Price - Previous EMA) * Multiplier + Previous EMA
|
|
62
|
+
previous_ema = ema_values.last
|
|
63
|
+
ema_values << (((price - previous_ema) * multiplier) + previous_ema)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ema_values
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Relative Strength Index (RSI)
|
|
72
|
+
#
|
|
73
|
+
# Momentum oscillator measuring speed and magnitude of price changes.
|
|
74
|
+
#
|
|
75
|
+
# @example Calculate 14-period RSI
|
|
76
|
+
# closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109, 111, 110, 112, 114, 113]
|
|
77
|
+
# rsi = DhanHQ::Indicators::RSI.calculate(closes, period: 14)
|
|
78
|
+
#
|
|
79
|
+
class RSI
|
|
80
|
+
# Calculate RSI for the given data.
|
|
81
|
+
#
|
|
82
|
+
# @param data [Array<Numeric>] Input price data
|
|
83
|
+
# @param period [Integer] Number of periods (default: 14)
|
|
84
|
+
# @return [Array<Float>] RSI values (nil for insufficient data points)
|
|
85
|
+
def self.calculate(data, period: 14)
|
|
86
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
87
|
+
return [] if data.length < period + 1
|
|
88
|
+
|
|
89
|
+
# Calculate price changes
|
|
90
|
+
changes = data.each_cons(2).map { |a, b| b - a }
|
|
91
|
+
|
|
92
|
+
# Calculate initial average gains and losses
|
|
93
|
+
gains = changes[0...period].select(&:positive?)
|
|
94
|
+
losses = changes[0...period].select(&:negative?).map(&:abs)
|
|
95
|
+
|
|
96
|
+
avg_gain = gains.sum.to_f / period
|
|
97
|
+
avg_loss = losses.sum.to_f / period
|
|
98
|
+
|
|
99
|
+
rsi_values = Array.new(period, nil)
|
|
100
|
+
|
|
101
|
+
# Calculate RSI for first period
|
|
102
|
+
rsi_values << calculate_rsi(avg_gain, avg_loss)
|
|
103
|
+
|
|
104
|
+
# Calculate subsequent RSI values
|
|
105
|
+
changes[period..].each do |change|
|
|
106
|
+
gain = change.positive? ? change : 0
|
|
107
|
+
loss = change.negative? ? change.abs : 0
|
|
108
|
+
|
|
109
|
+
avg_gain = ((avg_gain * (period - 1)) + gain) / period
|
|
110
|
+
avg_loss = ((avg_loss * (period - 1)) + loss) / period
|
|
111
|
+
|
|
112
|
+
rsi_values << calculate_rsi(avg_gain, avg_loss)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
rsi_values
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.calculate_rsi(avg_gain, avg_loss)
|
|
119
|
+
return 100.0 if avg_loss.zero?
|
|
120
|
+
return 0.0 if avg_gain.zero?
|
|
121
|
+
|
|
122
|
+
rs = avg_gain / avg_loss
|
|
123
|
+
100 - (100 / (1 + rs))
|
|
124
|
+
end
|
|
125
|
+
private_class_method :calculate_rsi
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Moving Average Convergence Divergence (MACD)
|
|
129
|
+
#
|
|
130
|
+
# Trend-following momentum indicator showing relationship between two EMAs.
|
|
131
|
+
#
|
|
132
|
+
# @example Calculate MACD
|
|
133
|
+
# closes = (0..99).map { |i| 100 + Math.sin(i * 0.1) * 10 }
|
|
134
|
+
# macd = DhanHQ::Indicators::MACD.calculate(closes)
|
|
135
|
+
# #=> { macd_line: [...], signal_line: [...], histogram: [...] }
|
|
136
|
+
#
|
|
137
|
+
class MACD
|
|
138
|
+
# Calculate MACD for the given data.
|
|
139
|
+
#
|
|
140
|
+
# @param data [Array<Numeric>] Input price data
|
|
141
|
+
# @param fast_period [Integer] Fast EMA period (default: 12)
|
|
142
|
+
# @param slow_period [Integer] Slow EMA period (default: 26)
|
|
143
|
+
# @param signal_period [Integer] Signal line period (default: 9)
|
|
144
|
+
# @return [Hash] Hash with :macd_line, :signal_line, and :histogram arrays
|
|
145
|
+
def self.calculate(data, fast_period: 12, slow_period: 26, signal_period: 9)
|
|
146
|
+
return { macd_line: [], signal_line: [], histogram: [] } if data.nil? || data.empty?
|
|
147
|
+
|
|
148
|
+
# Calculate fast and slow EMAs
|
|
149
|
+
fast_ema = EMA.calculate(data, period: fast_period)
|
|
150
|
+
slow_ema = EMA.calculate(data, period: slow_period)
|
|
151
|
+
|
|
152
|
+
# Calculate MACD line (fast EMA - slow EMA)
|
|
153
|
+
macd_line = fast_ema.zip(slow_ema).map do |fast, slow|
|
|
154
|
+
next nil if fast.nil? || slow.nil?
|
|
155
|
+
|
|
156
|
+
fast - slow
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Calculate signal line (EMA of MACD line)
|
|
160
|
+
valid_macd = macd_line.compact
|
|
161
|
+
signal_line_raw = valid_macd.empty? ? [] : EMA.calculate(valid_macd, period: signal_period)
|
|
162
|
+
|
|
163
|
+
# Align signal line with MACD line
|
|
164
|
+
signal_line = Array.new(macd_line.length - signal_line_raw.length, nil) + signal_line_raw
|
|
165
|
+
|
|
166
|
+
# Calculate histogram (MACD line - signal line)
|
|
167
|
+
histogram = macd_line.zip(signal_line).map do |macd, signal|
|
|
168
|
+
next nil if macd.nil? || signal.nil?
|
|
169
|
+
|
|
170
|
+
macd - signal
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
macd_line: macd_line,
|
|
175
|
+
signal_line: signal_line,
|
|
176
|
+
histogram: histogram
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Bollinger Bands
|
|
182
|
+
#
|
|
183
|
+
# Volatility indicator consisting of three lines: middle band (SMA), upper band, lower band.
|
|
184
|
+
#
|
|
185
|
+
# @example Calculate Bollinger Bands
|
|
186
|
+
# closes = (0..99).map { |i| 100 + Math.sin(i * 0.1) * 10 }
|
|
187
|
+
# bb = DhanHQ::Indicators::BollingerBands.calculate(closes)
|
|
188
|
+
# #=> { upper: [...], middle: [...], lower: [...] }
|
|
189
|
+
#
|
|
190
|
+
class BollingerBands
|
|
191
|
+
# Calculate Bollinger Bands for the given data.
|
|
192
|
+
#
|
|
193
|
+
# @param data [Array<Numeric>] Input price data
|
|
194
|
+
# @param period [Integer] Number of periods (default: 20)
|
|
195
|
+
# @param std_dev [Float] Number of standard deviations (default: 2.0)
|
|
196
|
+
# @return [Hash] Hash with :upper, :middle, and :lower arrays
|
|
197
|
+
def self.calculate(data, period: 20, std_dev: 2.0)
|
|
198
|
+
return { upper: [], middle: [], lower: [] } if data.nil? || data.empty?
|
|
199
|
+
|
|
200
|
+
# Calculate middle band (SMA)
|
|
201
|
+
middle = SMA.calculate(data, period: period)
|
|
202
|
+
|
|
203
|
+
# Calculate upper and lower bands
|
|
204
|
+
upper = []
|
|
205
|
+
lower = []
|
|
206
|
+
|
|
207
|
+
data.each_with_index do |_, i|
|
|
208
|
+
if i < period - 1
|
|
209
|
+
upper << nil
|
|
210
|
+
lower << nil
|
|
211
|
+
else
|
|
212
|
+
window = data[(i - period + 1)..i]
|
|
213
|
+
mean = middle[i]
|
|
214
|
+
variance = window.sum { |x| (x - mean)**2 } / period
|
|
215
|
+
std = Math.sqrt(variance)
|
|
216
|
+
|
|
217
|
+
upper << (mean + (std_dev * std))
|
|
218
|
+
lower << (mean - (std_dev * std))
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
upper: upper,
|
|
224
|
+
middle: middle,
|
|
225
|
+
lower: lower
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Average True Range (ATR)
|
|
231
|
+
#
|
|
232
|
+
# Volatility indicator measuring market volatility.
|
|
233
|
+
#
|
|
234
|
+
# @example Calculate ATR
|
|
235
|
+
# ohlc = [
|
|
236
|
+
# { open: 100, high: 105, low: 98, close: 103 },
|
|
237
|
+
# { open: 103, high: 108, low: 101, close: 106 },
|
|
238
|
+
# ...
|
|
239
|
+
# ]
|
|
240
|
+
# atr = DhanHQ::Indicators::ATR.calculate(ohlc, period: 14)
|
|
241
|
+
#
|
|
242
|
+
class ATR
|
|
243
|
+
# Calculate ATR for the given OHLC data.
|
|
244
|
+
#
|
|
245
|
+
# @param data [Array<Hash>] Array of OHLC hashes with :open, :high, :low, :close
|
|
246
|
+
# @param period [Integer] Number of periods (default: 14)
|
|
247
|
+
# @return [Array<Float>] ATR values (nil for insufficient data points)
|
|
248
|
+
def self.calculate(data, period: 14)
|
|
249
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
250
|
+
return [] if data.length < 2
|
|
251
|
+
|
|
252
|
+
# Calculate true ranges
|
|
253
|
+
true_ranges = data.each_cons(2).map do |prev, curr|
|
|
254
|
+
high = curr[:high] || curr["high"]
|
|
255
|
+
low = curr[:low] || curr["low"]
|
|
256
|
+
prev_close = prev[:close] || prev["close"]
|
|
257
|
+
|
|
258
|
+
[high - low, (high - prev_close).abs, (low - prev_close).abs].max
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Calculate ATR using smoothed average
|
|
262
|
+
atr_values = [nil] # First value has no true range
|
|
263
|
+
|
|
264
|
+
# First ATR is simple average
|
|
265
|
+
if true_ranges.length >= period
|
|
266
|
+
first_atr = true_ranges[0...period].sum.to_f / period
|
|
267
|
+
atr_values.concat(Array.new(period - 1, nil))
|
|
268
|
+
atr_values << first_atr
|
|
269
|
+
|
|
270
|
+
# Subsequent ATRs use smoothing
|
|
271
|
+
true_ranges[period..].each do |tr|
|
|
272
|
+
previous_atr = atr_values.last
|
|
273
|
+
atr_values << (((previous_atr * (period - 1)) + tr) / period)
|
|
274
|
+
end
|
|
275
|
+
else
|
|
276
|
+
atr_values.concat(Array.new(true_ranges.length, nil))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
atr_values
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Higher-level abstractions for market data consumption.
|
|
5
|
+
module MarketData
|
|
6
|
+
# A snapshot of market data for multiple instruments at a point in time.
|
|
7
|
+
#
|
|
8
|
+
# Wraps the raw MarketFeed response into a more convenient structure
|
|
9
|
+
# with typed accessors and helper methods.
|
|
10
|
+
#
|
|
11
|
+
# @example Build a snapshot from MarketFeed response
|
|
12
|
+
# response = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [11536, 3456])
|
|
13
|
+
# snapshot = DhanHQ::MarketData::MarketSnapshot.from_response(response)
|
|
14
|
+
# snapshot.ltp("NSE_EQ", "11536") #=> 2850.50
|
|
15
|
+
#
|
|
16
|
+
class MarketSnapshot
|
|
17
|
+
attr_reader :data, :fetched_at
|
|
18
|
+
|
|
19
|
+
def initialize(data = {})
|
|
20
|
+
@data = data
|
|
21
|
+
@fetched_at = Time.now
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Build a MarketSnapshot from a raw MarketFeed API response.
|
|
25
|
+
def self.from_response(response)
|
|
26
|
+
raw_data = response.is_a?(Hash) ? (response[:data] || response["data"] || {}) : {}
|
|
27
|
+
new(normalize_data(raw_data))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get LTP for a specific instrument.
|
|
31
|
+
def ltp(exchange_segment, security_id)
|
|
32
|
+
instrument_data(exchange_segment, security_id)&.dig(:last_price)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get OHLC for a specific instrument.
|
|
36
|
+
def ohlc(exchange_segment, security_id)
|
|
37
|
+
instrument_data(exchange_segment, security_id)&.dig(:ohlc)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get full quote (market depth) for a specific instrument.
|
|
41
|
+
def quote(exchange_segment, security_id)
|
|
42
|
+
instrument_data(exchange_segment, security_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get all instruments for a specific exchange segment.
|
|
46
|
+
def for_segment(exchange_segment)
|
|
47
|
+
@data[exchange_segment.to_s] || {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get all security IDs across all segments.
|
|
51
|
+
def security_ids
|
|
52
|
+
@data.each_with_object([]) do |(_segment, instruments), ids|
|
|
53
|
+
ids.concat(instruments.keys)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get total number of instruments in the snapshot.
|
|
58
|
+
def size
|
|
59
|
+
@data.values.sum(&:size)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if the snapshot is empty.
|
|
63
|
+
def empty?
|
|
64
|
+
@data.empty? || @data.values.all?(&:empty?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def instrument_data(exchange_segment, security_id)
|
|
70
|
+
@data[exchange_segment.to_s]&.dig(security_id.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.normalize_data(data)
|
|
74
|
+
result = {}
|
|
75
|
+
data.each do |segment, instruments|
|
|
76
|
+
result[segment.to_s] = {}
|
|
77
|
+
next unless instruments.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
instruments.each do |sec_id, info|
|
|
80
|
+
result[segment.to_s][sec_id.to_s] = normalize_instrument(info)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
private_class_method :normalize_data
|
|
86
|
+
|
|
87
|
+
def self.normalize_instrument(info)
|
|
88
|
+
return info unless info.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
info.each_with_object({}) do |(key, value), hash|
|
|
91
|
+
hash[key.to_sym] = value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
private_class_method :normalize_instrument
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -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
|