DhanHQ 2.1.0 → 2.1.3
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/README.md +17 -11
- data/docs/technical_analysis.md +143 -0
- data/lib/DhanHQ/constants.rb +4 -6
- data/lib/DhanHQ/contracts/instrument_list_contract.rb +12 -0
- data/lib/DhanHQ/helpers/request_helper.rb +5 -1
- data/lib/DhanHQ/models/instrument.rb +56 -0
- data/lib/DhanHQ/resources/instruments.rb +28 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ.rb +8 -0
- data/lib/dhanhq/analysis/helpers/bias_aggregator.rb +83 -0
- data/lib/dhanhq/analysis/helpers/moneyness_helper.rb +23 -0
- data/lib/dhanhq/analysis/multi_timeframe_analyzer.rb +230 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +250 -0
- data/lib/dhanhq/contracts/options_buying_advisor_contract.rb +23 -0
- data/lib/ta/candles.rb +51 -0
- data/lib/ta/fetcher.rb +69 -0
- data/lib/ta/indicators.rb +168 -0
- data/lib/ta/market_calendar.rb +50 -0
- data/lib/ta/technical_analysis.rb +92 -302
- data/lib/ta.rb +7 -0
- metadata +15 -1
@@ -0,0 +1,230 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "dry/validation"
|
5
|
+
|
6
|
+
module DhanHQ
|
7
|
+
module Analysis
|
8
|
+
class MultiTimeframeAnalyzer
|
9
|
+
class InputContract < Dry::Validation::Contract
|
10
|
+
params do
|
11
|
+
required(:meta).filled(:hash)
|
12
|
+
required(:indicators).filled(:hash)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
TF_ORDER = %i[m1 m5 m15 m25 m60].freeze
|
17
|
+
|
18
|
+
def initialize(data:)
|
19
|
+
@data = symbolize(data)
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
validate_data!
|
24
|
+
per_tf = compute_indicators(@data[:indicators])
|
25
|
+
aggregate_results(per_tf)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def symbolize(obj)
|
31
|
+
case obj
|
32
|
+
when Hash
|
33
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
|
34
|
+
when Array
|
35
|
+
obj.map { |v| symbolize(v) }
|
36
|
+
else
|
37
|
+
obj
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def validate_data!
|
42
|
+
res = InputContract.new.call(@data)
|
43
|
+
raise ArgumentError, res.errors.to_h.inspect unless res.success?
|
44
|
+
end
|
45
|
+
|
46
|
+
def compute_indicators(indicators)
|
47
|
+
TF_ORDER.each_with_object({}) do |tf, out|
|
48
|
+
next unless indicators.key?(tf)
|
49
|
+
|
50
|
+
val = indicators[tf]
|
51
|
+
rsi = val[:rsi]
|
52
|
+
adx = val[:adx]
|
53
|
+
atr = val[:atr]
|
54
|
+
macd = val[:macd] || {}
|
55
|
+
macd_line = macd[:macd]
|
56
|
+
macd_signal = macd[:signal]
|
57
|
+
macd_hist = macd[:hist]
|
58
|
+
|
59
|
+
momentum = classify_rsi(rsi)
|
60
|
+
trend = classify_adx(adx)
|
61
|
+
macd_sig = classify_macd(macd_line, macd_signal, macd_hist)
|
62
|
+
vol = classify_atr(atr)
|
63
|
+
|
64
|
+
out[tf] = {
|
65
|
+
rsi: rsi, adx: adx, atr: atr, macd: macd,
|
66
|
+
momentum: momentum, trend: trend, macd_signal: macd_sig, volatility: vol,
|
67
|
+
bias: derive_bias(momentum, macd_sig)
|
68
|
+
}
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def classify_rsi(rsi)
|
73
|
+
return :unknown if rsi.nil?
|
74
|
+
return :overbought if rsi >= 70
|
75
|
+
return :oversold if rsi <= 30
|
76
|
+
return :bullish if rsi >= 55
|
77
|
+
return :bearish if rsi <= 45
|
78
|
+
|
79
|
+
:neutral
|
80
|
+
end
|
81
|
+
|
82
|
+
def classify_adx(adx)
|
83
|
+
return :unknown if adx.nil?
|
84
|
+
return :strong if adx >= 25
|
85
|
+
return :weak if adx <= 15
|
86
|
+
|
87
|
+
:moderate
|
88
|
+
end
|
89
|
+
|
90
|
+
def classify_macd(macd, signal, hist)
|
91
|
+
return :unknown if macd.nil? || signal.nil?
|
92
|
+
|
93
|
+
# Treat histogram sign and distance as proxy for momentum direction
|
94
|
+
if macd > signal && (hist.nil? || hist >= 0)
|
95
|
+
:bullish
|
96
|
+
elsif macd < signal && (hist.nil? || hist <= 0)
|
97
|
+
:bearish
|
98
|
+
else
|
99
|
+
:neutral
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def classify_atr(atr)
|
104
|
+
return :unknown if atr.nil?
|
105
|
+
|
106
|
+
# ATR is relative; without baseline we can only tag as present
|
107
|
+
atr.positive? ? :expanding : :flat
|
108
|
+
end
|
109
|
+
|
110
|
+
def derive_bias(momentum, macd_sig)
|
111
|
+
return :bullish if momentum == :bullish && macd_sig == :bullish
|
112
|
+
return :bearish if momentum == :bearish && macd_sig == :bearish
|
113
|
+
|
114
|
+
:neutral
|
115
|
+
end
|
116
|
+
|
117
|
+
def aggregate_results(per_tf)
|
118
|
+
weights = { m1: 1, m5: 2, m15: 3, m25: 3, m60: 4 }
|
119
|
+
scores = { bullish: 1.0, neutral: 0.5, bearish: 0.0 }
|
120
|
+
|
121
|
+
total_w = 0.0
|
122
|
+
acc = 0.0
|
123
|
+
per_tf.each do |tf, s|
|
124
|
+
w = weights[tf] || 1
|
125
|
+
total_w += w
|
126
|
+
acc += (scores[s[:bias]] || 0.5) * w
|
127
|
+
end
|
128
|
+
avg = total_w.zero? ? 0.5 : (acc / total_w)
|
129
|
+
|
130
|
+
bias = if avg >= 0.66
|
131
|
+
:bullish
|
132
|
+
elsif avg <= 0.33
|
133
|
+
:bearish
|
134
|
+
else
|
135
|
+
:neutral
|
136
|
+
end
|
137
|
+
|
138
|
+
setup = if bias == :bullish
|
139
|
+
:buy_on_dip
|
140
|
+
elsif bias == :bearish
|
141
|
+
:sell_on_rise
|
142
|
+
else
|
143
|
+
:range_trade
|
144
|
+
end
|
145
|
+
|
146
|
+
rationale = build_rationale(per_tf)
|
147
|
+
trend_strength = build_trend_strength(per_tf)
|
148
|
+
|
149
|
+
{
|
150
|
+
meta: (@data[:meta] || {}).slice(:security_id, :instrument, :exchange_segment),
|
151
|
+
summary: {
|
152
|
+
bias: bias,
|
153
|
+
setup: setup,
|
154
|
+
confidence: avg.round(2),
|
155
|
+
rationale: rationale,
|
156
|
+
trend_strength: trend_strength
|
157
|
+
}
|
158
|
+
}
|
159
|
+
end
|
160
|
+
|
161
|
+
def build_rationale(per_tf)
|
162
|
+
{
|
163
|
+
rsi: rsi_rationale(per_tf),
|
164
|
+
macd: macd_rationale(per_tf),
|
165
|
+
adx: adx_rationale(per_tf),
|
166
|
+
atr: atr_rationale(per_tf)
|
167
|
+
}
|
168
|
+
end
|
169
|
+
|
170
|
+
def rsi_rationale(per_tf)
|
171
|
+
ups = per_tf.count { |_tf, s| %i[bullish overbought].include?(s[:momentum]) }
|
172
|
+
downs = per_tf.count { |_tf, s| %i[bearish oversold].include?(s[:momentum]) }
|
173
|
+
if ups > downs
|
174
|
+
"Upward momentum across #{ups} TFs"
|
175
|
+
elsif downs > ups
|
176
|
+
"Downward momentum across #{downs} TFs"
|
177
|
+
else
|
178
|
+
"Mixed RSI momentum"
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def macd_rationale(per_tf)
|
183
|
+
ups = per_tf.count { |_tf, s| s[:macd_signal] == :bullish }
|
184
|
+
downs = per_tf.count { |_tf, s| s[:macd_signal] == :bearish }
|
185
|
+
if ups > downs
|
186
|
+
"MACD bullish signals dominant"
|
187
|
+
elsif downs > ups
|
188
|
+
"MACD bearish signals dominant"
|
189
|
+
else
|
190
|
+
"MACD mixed/neutral"
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def adx_rationale(per_tf)
|
195
|
+
strong = per_tf.count { |_tf, s| s[:trend] == :strong }
|
196
|
+
return "Strong higher timeframe trend" if strong >= 2
|
197
|
+
|
198
|
+
moderate = per_tf.count { |_tf, s| s[:trend] == :moderate }
|
199
|
+
return "Moderate trend context" if moderate >= 2
|
200
|
+
|
201
|
+
"Weak/unknown trend context"
|
202
|
+
end
|
203
|
+
|
204
|
+
def atr_rationale(per_tf)
|
205
|
+
exp = per_tf.count { |_tf, s| s[:volatility] == :expanding }
|
206
|
+
exp.positive? ? "Volatility expansion" : "Low/flat volatility"
|
207
|
+
end
|
208
|
+
|
209
|
+
def build_trend_strength(per_tf)
|
210
|
+
{
|
211
|
+
short_term: summarize_bias(%i[m1 m5], per_tf),
|
212
|
+
medium_term: summarize_bias(%i[m15 m25], per_tf),
|
213
|
+
long_term: summarize_bias(%i[m60], per_tf)
|
214
|
+
}
|
215
|
+
end
|
216
|
+
|
217
|
+
def summarize_bias(tfs, per_tf)
|
218
|
+
slice = per_tf.slice(*tfs)
|
219
|
+
ups = slice.count { |_tf, s| s[:bias] == :bullish }
|
220
|
+
downs = slice.count { |_tf, s| s[:bias] == :bearish }
|
221
|
+
return :strong_bullish if ups >= 2
|
222
|
+
return :strong_bearish if downs >= 2
|
223
|
+
return :weak_bullish if ups == 1 && downs.zero?
|
224
|
+
return :weak_bearish if downs == 1 && ups.zero?
|
225
|
+
|
226
|
+
:neutral
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
@@ -0,0 +1,250 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "helpers/bias_aggregator"
|
4
|
+
require_relative "helpers/moneyness_helper"
|
5
|
+
require_relative "../contracts/options_buying_advisor_contract"
|
6
|
+
|
7
|
+
module DhanHQ
|
8
|
+
module Analysis
|
9
|
+
class OptionsBuyingAdvisor
|
10
|
+
DEFAULT_CONFIG = {
|
11
|
+
timeframe_weights: { m1: 0.1, m5: 0.2, m15: 0.25, m25: 0.15, m60: 0.3 },
|
12
|
+
min_adx_for_trend: 22,
|
13
|
+
strong_adx: 35,
|
14
|
+
min_oi: 1_000,
|
15
|
+
max_spread_pct: 3.0,
|
16
|
+
preferred_deltas: {
|
17
|
+
ce: { otm: (0.35..0.45), atm: (0.48..0.52), itm: (0.55..0.70) },
|
18
|
+
pe: { otm: (-0.45..-0.35), atm: (-0.52..-0.48), itm: (-0.70..-0.55) }
|
19
|
+
},
|
20
|
+
risk: { sl_pct: 0.30, tp_pct: 0.60, trail_arm_pct: 0.20, trail_step_pct: 0.10 },
|
21
|
+
atr_to_rupees_factor: 1.0,
|
22
|
+
min_confidence: 0.58
|
23
|
+
}.freeze
|
24
|
+
|
25
|
+
def initialize(data:, config: {})
|
26
|
+
@data = deep_symbolize(data || {})
|
27
|
+
@config = deep_merge(DEFAULT_CONFIG, config || {})
|
28
|
+
end
|
29
|
+
|
30
|
+
def call
|
31
|
+
validate!
|
32
|
+
return unsupported("unsupported instrument") unless index_instrument?(@data[:meta])
|
33
|
+
|
34
|
+
ensure_option_chain!
|
35
|
+
|
36
|
+
bias = BiasAggregator.new(@data[:indicators], @config).call
|
37
|
+
# Neutral override: if higher TF trend is strong and short-term momentum aligns, allow a modest-confidence entry
|
38
|
+
bias = neutral_override(bias) if bias[:bias] == :neutral
|
39
|
+
if bias[:bias] == :neutral || bias[:confidence].to_f < @config[:min_confidence].to_f
|
40
|
+
return no_trade("neutral/low confidence")
|
41
|
+
end
|
42
|
+
|
43
|
+
side = bias[:bias] == :bullish ? :ce : :pe
|
44
|
+
moneyness = MoneynessHelper.pick_moneyness(indicators: @data[:indicators],
|
45
|
+
min_adx: @config[:min_adx_for_trend],
|
46
|
+
strong_adx: @config[:strong_adx],
|
47
|
+
bias: bias[:bias])
|
48
|
+
strike_pick = select_strike(side: side, moneyness: moneyness)
|
49
|
+
return no_trade("no liquid strikes passed filters") unless strike_pick
|
50
|
+
|
51
|
+
build_recommendation(side: side, moneyness: moneyness, bias: bias, strike_pick: strike_pick)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# If bias is neutral, try to infer a directional tilt using strong higher timeframe ADX and M5/M15 momentum
|
57
|
+
def neutral_override(bias)
|
58
|
+
ind = @data[:indicators] || {}
|
59
|
+
m60 = ind[:m60] || {}
|
60
|
+
m5 = ind[:m5] || {}
|
61
|
+
m15 = ind[:m15] || {}
|
62
|
+
|
63
|
+
adx60 = m60[:adx].to_f
|
64
|
+
strong = adx60 >= @config[:strong_adx].to_f
|
65
|
+
return bias unless strong
|
66
|
+
|
67
|
+
# Simple momentum checks
|
68
|
+
rsi5 = m5[:rsi]
|
69
|
+
rsi15 = m15[:rsi]
|
70
|
+
macd5 = (m5[:macd] || {})[:hist]
|
71
|
+
macd15 = (m15[:macd] || {})[:hist]
|
72
|
+
|
73
|
+
bullish = (rsi5 && rsi5 >= 55) || (rsi15 && rsi15 >= 55) || (macd5 && macd5 >= 0) || (macd15 && macd15 >= 0)
|
74
|
+
bearish = (rsi5 && rsi5 <= 45) || (rsi15 && rsi15 <= 45) || (macd5 && macd5 <= 0) || (macd15 && macd15 <= 0)
|
75
|
+
|
76
|
+
if bullish && !bearish
|
77
|
+
return { bias: :bullish, confidence: [@config[:min_confidence].to_f, 0.62].max, refs: %i[m5 m15 m60],
|
78
|
+
notes: ["Override: strong M60 ADX with bullish M5/M15 momentum"] }
|
79
|
+
end
|
80
|
+
if bearish && !bullish
|
81
|
+
return { bias: :bearish, confidence: [@config[:min_confidence].to_f, 0.62].max, refs: %i[m5 m15 m60],
|
82
|
+
notes: ["Override: strong M60 ADX with bearish M5/M15 momentum"] }
|
83
|
+
end
|
84
|
+
|
85
|
+
bias
|
86
|
+
end
|
87
|
+
|
88
|
+
def ensure_option_chain!
|
89
|
+
return if Array(@data[:option_chain]).any?
|
90
|
+
|
91
|
+
# Use OptionChain model: pick nearest/next expiry and fetch chain
|
92
|
+
sid = @data.dig(:meta, :security_id)
|
93
|
+
seg = @data.dig(:meta, :exchange_segment) || "IDX_I"
|
94
|
+
return unless sid && seg
|
95
|
+
|
96
|
+
expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(underlying_scrip: sid.to_i, underlying_seg: seg)
|
97
|
+
return if expiries.empty?
|
98
|
+
|
99
|
+
# Choose the nearest expiry (first element); adjust selection if API returns sorted differently
|
100
|
+
expiry = expiries.first
|
101
|
+
raw = DhanHQ::Models::OptionChain.fetch(underlying_scrip: sid.to_i, underlying_seg: seg, expiry: expiry)
|
102
|
+
oc = raw[:oc] || {}
|
103
|
+
# Transform OC structure into advisor-friendly array [{ strike:, ce: {...}, pe: {...} }]
|
104
|
+
@data[:option_chain] = oc.map do |strike, strike_data|
|
105
|
+
{
|
106
|
+
strike: strike.to_f,
|
107
|
+
ce: normalize_leg(strike_data["ce"] || {}),
|
108
|
+
pe: normalize_leg(strike_data["pe"] || {})
|
109
|
+
}
|
110
|
+
end
|
111
|
+
rescue StandardError
|
112
|
+
@data[:option_chain] ||= []
|
113
|
+
end
|
114
|
+
|
115
|
+
def normalize_leg(cepe)
|
116
|
+
{
|
117
|
+
ltp: cepe["last_price"], bid: cepe["best_bid_price"], ask: cepe["best_ask_price"],
|
118
|
+
iv: cepe["iv"], oi: cepe["oi"], volume: cepe["volume"],
|
119
|
+
delta: cepe["delta"], gamma: cepe["gamma"], vega: cepe["vega"], theta: cepe["theta"],
|
120
|
+
lot_size: cepe["lot_size"], tradable: true
|
121
|
+
}
|
122
|
+
end
|
123
|
+
|
124
|
+
def validate!
|
125
|
+
res = DhanHQ::Contracts::OptionsBuyingAdvisorContract.new.call(@data)
|
126
|
+
raise ArgumentError, res.errors.to_h.inspect unless res.success?
|
127
|
+
end
|
128
|
+
|
129
|
+
def index_instrument?(meta)
|
130
|
+
meta[:instrument].to_s == "INDEX" || meta[:exchange_segment].to_s == "IDX_I"
|
131
|
+
end
|
132
|
+
|
133
|
+
def select_strike(side:, moneyness:)
|
134
|
+
chain = Array(@data[:option_chain])
|
135
|
+
return nil if chain.empty?
|
136
|
+
|
137
|
+
target_range = @config[:preferred_deltas][side][moneyness]
|
138
|
+
best = nil
|
139
|
+
chain.each do |row|
|
140
|
+
leg = row[side]
|
141
|
+
next unless leg && leg[:tradable]
|
142
|
+
next if leg[:oi].to_i < @config[:min_oi]
|
143
|
+
|
144
|
+
spread = spread_pct(leg[:bid], leg[:ask])
|
145
|
+
next if spread.nil? || spread > @config[:max_spread_pct]
|
146
|
+
|
147
|
+
delta = leg[:delta]
|
148
|
+
next unless delta && target_range.cover?(delta)
|
149
|
+
|
150
|
+
candidate = { strike: row[:strike], leg: leg, spread: spread, oi: leg[:oi].to_i, delta: delta }
|
151
|
+
best = rank_pick(best, candidate)
|
152
|
+
end
|
153
|
+
best
|
154
|
+
end
|
155
|
+
|
156
|
+
def spread_pct(bid, ask)
|
157
|
+
return nil if bid.to_f <= 0.0 || ask.to_f <= 0.0
|
158
|
+
|
159
|
+
mid = (bid.to_f + ask.to_f) / 2.0
|
160
|
+
return nil if mid <= 0.0
|
161
|
+
|
162
|
+
((ask.to_f - bid.to_f) / mid) * 100.0
|
163
|
+
end
|
164
|
+
|
165
|
+
def rank_pick(best, cand)
|
166
|
+
return cand unless best
|
167
|
+
|
168
|
+
target_center = cand[:delta] >= 0 ? 0.5 : -0.5
|
169
|
+
best_score = [delta_distance(best[:delta], target_center), best[:spread], -best[:oi]]
|
170
|
+
cand_score = [delta_distance(cand[:delta], target_center), cand[:spread], -cand[:oi]]
|
171
|
+
cand_score < best_score ? cand : best
|
172
|
+
end
|
173
|
+
|
174
|
+
def delta_distance(delta, center)
|
175
|
+
(delta.to_f - center).abs
|
176
|
+
end
|
177
|
+
|
178
|
+
def build_recommendation(side:, moneyness:, bias:, strike_pick:)
|
179
|
+
risk = compute_risk(strike_pick[:leg])
|
180
|
+
{
|
181
|
+
meta: { symbol: @data.dig(:meta, :symbol), security_id: @data.dig(:meta, :security_id), ts: Time.now },
|
182
|
+
decision: :enter_long,
|
183
|
+
side: side,
|
184
|
+
moneyness: moneyness,
|
185
|
+
rationale: {
|
186
|
+
bias: bias[:bias],
|
187
|
+
confidence: bias[:confidence],
|
188
|
+
notes: bias[:notes]
|
189
|
+
},
|
190
|
+
instrument: {
|
191
|
+
spot: @data[:spot],
|
192
|
+
ref_timeframes: bias[:refs],
|
193
|
+
atr_rupees_hint: atr_to_rupees
|
194
|
+
},
|
195
|
+
strike: {
|
196
|
+
recommended: strike_pick[:strike],
|
197
|
+
alternatives: [],
|
198
|
+
selection_basis: "delta window, spread, OI"
|
199
|
+
},
|
200
|
+
risk: risk
|
201
|
+
}
|
202
|
+
end
|
203
|
+
|
204
|
+
def atr_to_rupees
|
205
|
+
m15 = @data.dig(:indicators, :m15, :atr)
|
206
|
+
return nil unless m15
|
207
|
+
|
208
|
+
(m15.to_f * @config[:atr_to_rupees_factor].to_f).round(2)
|
209
|
+
end
|
210
|
+
|
211
|
+
def compute_risk(leg)
|
212
|
+
entry = leg[:ltp].to_f
|
213
|
+
sl = (entry * (1.0 - @config.dig(:risk, :sl_pct).to_f)).round(2)
|
214
|
+
tp = (entry * (1.0 + @config.dig(:risk, :tp_pct).to_f)).round(2)
|
215
|
+
{
|
216
|
+
entry_ltp: entry,
|
217
|
+
sl_ltp: sl,
|
218
|
+
tp_ltp: tp,
|
219
|
+
trail: { start_at_gain_pct: (@config.dig(:risk, :trail_arm_pct) * 100).to_i,
|
220
|
+
step_pct: (@config.dig(:risk, :trail_step_pct) * 100).to_i }
|
221
|
+
}
|
222
|
+
end
|
223
|
+
|
224
|
+
def unsupported(reason)
|
225
|
+
{ decision: :no_trade, reason: reason }
|
226
|
+
end
|
227
|
+
|
228
|
+
def no_trade(reason)
|
229
|
+
{ decision: :no_trade, reason: reason }
|
230
|
+
end
|
231
|
+
|
232
|
+
def deep_merge(a, b)
|
233
|
+
return a unless b
|
234
|
+
|
235
|
+
a.merge(b) { |_, av, bv| av.is_a?(Hash) && bv.is_a?(Hash) ? deep_merge(av, bv) : bv }
|
236
|
+
end
|
237
|
+
|
238
|
+
def deep_symbolize(obj)
|
239
|
+
case obj
|
240
|
+
when Hash
|
241
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
242
|
+
when Array
|
243
|
+
obj.map { |v| deep_symbolize(v) }
|
244
|
+
else
|
245
|
+
obj
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/validation"
|
4
|
+
|
5
|
+
module DhanHQ
|
6
|
+
module Contracts
|
7
|
+
class OptionsBuyingAdvisorContract < Dry::Validation::Contract
|
8
|
+
params do
|
9
|
+
required(:meta).hash do
|
10
|
+
required(:exchange_segment).filled(:string)
|
11
|
+
required(:instrument).filled(:string)
|
12
|
+
required(:security_id).filled(:string)
|
13
|
+
optional(:symbol).maybe(:string)
|
14
|
+
optional(:timestamp)
|
15
|
+
end
|
16
|
+
required(:spot).filled(:float)
|
17
|
+
required(:indicators).hash
|
18
|
+
optional(:option_chain).array(:hash)
|
19
|
+
optional(:config).hash
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/ta/candles.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
|
5
|
+
module TA
|
6
|
+
module Candles
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def parse_time_like(val)
|
10
|
+
return Time.at(val) if val.is_a?(Numeric)
|
11
|
+
|
12
|
+
s = val.to_s
|
13
|
+
return Time.at(s.to_i) if /\A\d+\z/.match?(s) && s.length >= 10 && s.length <= 13
|
14
|
+
|
15
|
+
Time.parse(s)
|
16
|
+
end
|
17
|
+
|
18
|
+
def from_series(series)
|
19
|
+
ts = series["timestamp"] || series[:timestamp]
|
20
|
+
open = series["open"] || series[:open]
|
21
|
+
high = series["high"] || series[:high]
|
22
|
+
low = series["low"] || series[:low]
|
23
|
+
close = series["close"] || series[:close]
|
24
|
+
vol = series["volume"] || series[:volume]
|
25
|
+
return [] unless ts && open && high && low && close && vol
|
26
|
+
return [] if close.empty?
|
27
|
+
|
28
|
+
(0...close.size).map do |i|
|
29
|
+
{ t: parse_time_like(ts[i]), o: open[i].to_f, h: high[i].to_f, l: low[i].to_f, c: close[i].to_f,
|
30
|
+
v: vol[i].to_f }
|
31
|
+
end
|
32
|
+
rescue StandardError
|
33
|
+
[]
|
34
|
+
end
|
35
|
+
|
36
|
+
def resample(candles, minutes)
|
37
|
+
return candles if minutes.to_i == 1
|
38
|
+
|
39
|
+
grouped = {}
|
40
|
+
candles.each do |c|
|
41
|
+
key = Time.at((c[:t].to_i / 60) / minutes * minutes * 60)
|
42
|
+
b = (grouped[key] ||= { t: key, o: c[:o], h: c[:h], l: c[:l], c: c[:c], v: 0.0 })
|
43
|
+
b[:h] = [b[:h], c[:h]].max
|
44
|
+
b[:l] = [b[:l], c[:l]].min
|
45
|
+
b[:c] = c[:c]
|
46
|
+
b[:v] += c[:v]
|
47
|
+
end
|
48
|
+
grouped.keys.sort.map { |k| grouped[k] }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/ta/fetcher.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "date"
|
4
|
+
|
5
|
+
module TA
|
6
|
+
class Fetcher
|
7
|
+
def initialize(throttle_seconds: 3.0, max_retries: 3)
|
8
|
+
@throttle_seconds = throttle_seconds.to_f
|
9
|
+
@max_retries = max_retries.to_i
|
10
|
+
end
|
11
|
+
|
12
|
+
def intraday(params, interval)
|
13
|
+
with_retries(":intraday/#{interval}") do
|
14
|
+
DhanHQ::Models::HistoricalData.intraday(
|
15
|
+
security_id: params[:security_id],
|
16
|
+
exchange_segment: params[:exchange_segment],
|
17
|
+
instrument: params[:instrument],
|
18
|
+
interval: interval.to_s,
|
19
|
+
from_date: params[:from_date],
|
20
|
+
to_date: params[:to_date]
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def intraday_windowed(params, interval)
|
26
|
+
from_d = Date.parse(params[:from_date])
|
27
|
+
to_d = Date.parse(params[:to_date])
|
28
|
+
max_span = 90
|
29
|
+
return intraday(params, interval) if (to_d - from_d).to_i <= max_span
|
30
|
+
|
31
|
+
merged = { "open" => [], "high" => [], "low" => [], "close" => [], "volume" => [], "timestamp" => [] }
|
32
|
+
cursor = from_d
|
33
|
+
while cursor <= to_d
|
34
|
+
chunk_to = [cursor + max_span, to_d].min
|
35
|
+
chunk_params = params.merge(from_date: cursor.strftime("%Y-%m-%d"), to_date: chunk_to.strftime("%Y-%m-%d"))
|
36
|
+
part = intraday(chunk_params, interval)
|
37
|
+
%w[open high low close volume timestamp].each do |k|
|
38
|
+
ary = (part[k] || part[k.to_sym]) || []
|
39
|
+
merged[k].concat(Array(ary))
|
40
|
+
end
|
41
|
+
cursor = chunk_to + 1
|
42
|
+
sleep_with_jitter
|
43
|
+
end
|
44
|
+
merged
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def sleep_with_jitter(multiplier = 1.0)
|
50
|
+
base = @throttle_seconds * multiplier
|
51
|
+
jitter = rand * 0.3
|
52
|
+
sleep(base + jitter)
|
53
|
+
end
|
54
|
+
|
55
|
+
def with_retries(_label)
|
56
|
+
retries = 0
|
57
|
+
begin
|
58
|
+
yield
|
59
|
+
rescue DhanHQ::RateLimitError => e
|
60
|
+
retries += 1
|
61
|
+
raise e if retries > @max_retries
|
62
|
+
|
63
|
+
backoff = [5 * retries, 30].min
|
64
|
+
sleep_with_jitter(backoff / 3.0)
|
65
|
+
retry
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|