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.
@@ -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