DhanHQ 2.1.0 → 2.1.5

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +185 -0
  4. data/CHANGELOG.md +24 -0
  5. data/GUIDE.md +44 -44
  6. data/README.md +40 -14
  7. data/docs/rails_integration.md +1 -1
  8. data/docs/technical_analysis.md +144 -0
  9. data/lib/DhanHQ/config.rb +1 -0
  10. data/lib/DhanHQ/constants.rb +4 -6
  11. data/lib/DhanHQ/contracts/instrument_list_contract.rb +12 -0
  12. data/lib/DhanHQ/contracts/modify_order_contract.rb +1 -0
  13. data/lib/DhanHQ/contracts/option_chain_contract.rb +11 -1
  14. data/lib/DhanHQ/helpers/request_helper.rb +5 -1
  15. data/lib/DhanHQ/models/instrument.rb +56 -0
  16. data/lib/DhanHQ/models/option_chain.rb +2 -0
  17. data/lib/DhanHQ/rate_limiter.rb +4 -2
  18. data/lib/DhanHQ/resources/instruments.rb +28 -0
  19. data/lib/DhanHQ/version.rb +1 -1
  20. data/lib/DhanHQ/ws/client.rb +1 -1
  21. data/lib/DhanHQ/ws/connection.rb +1 -1
  22. data/lib/DhanHQ/ws/orders/client.rb +3 -0
  23. data/lib/DhanHQ/ws/orders/connection.rb +5 -6
  24. data/lib/DhanHQ/ws/orders.rb +3 -2
  25. data/lib/DhanHQ/ws/registry.rb +1 -0
  26. data/lib/DhanHQ/ws/segments.rb +4 -4
  27. data/lib/DhanHQ/ws/sub_state.rb +1 -1
  28. data/lib/{DhanHQ.rb → dhan_hq.rb} +8 -0
  29. data/lib/dhanhq/analysis/helpers/bias_aggregator.rb +83 -0
  30. data/lib/dhanhq/analysis/helpers/moneyness_helper.rb +24 -0
  31. data/lib/dhanhq/analysis/multi_timeframe_analyzer.rb +232 -0
  32. data/lib/dhanhq/analysis/options_buying_advisor.rb +251 -0
  33. data/lib/dhanhq/contracts/options_buying_advisor_contract.rb +24 -0
  34. data/lib/ta/candles.rb +52 -0
  35. data/lib/ta/fetcher.rb +70 -0
  36. data/lib/ta/indicators.rb +169 -0
  37. data/lib/ta/market_calendar.rb +51 -0
  38. data/lib/ta/technical_analysis.rb +94 -303
  39. data/lib/ta.rb +7 -0
  40. metadata +18 -4
  41. data/lib/DhanHQ/ws/errors.rb +0 -0
  42. /data/lib/DhanHQ/contracts/{modify_order_contract copy.rb → modify_order_contract_copy.rb} +0 -0
@@ -0,0 +1,251 @@
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
+ # Options buying advisor for INDEX options (NIFTY/BANKNIFTY/SENSEX)
10
+ class OptionsBuyingAdvisor
11
+ DEFAULT_CONFIG = {
12
+ timeframe_weights: { m1: 0.1, m5: 0.2, m15: 0.25, m25: 0.15, m60: 0.3 },
13
+ min_adx_for_trend: 22,
14
+ strong_adx: 35,
15
+ min_oi: 1_000,
16
+ max_spread_pct: 3.0,
17
+ preferred_deltas: {
18
+ ce: { otm: (0.35..0.45), atm: (0.48..0.52), itm: (0.55..0.70) },
19
+ pe: { otm: (-0.45..-0.35), atm: (-0.52..-0.48), itm: (-0.70..-0.55) }
20
+ },
21
+ risk: { sl_pct: 0.30, tp_pct: 0.60, trail_arm_pct: 0.20, trail_step_pct: 0.10 },
22
+ atr_to_rupees_factor: 1.0,
23
+ min_confidence: 0.58
24
+ }.freeze
25
+
26
+ def initialize(data:, config: {})
27
+ @data = deep_symbolize(data || {})
28
+ @config = deep_merge(DEFAULT_CONFIG, config || {})
29
+ end
30
+
31
+ def call
32
+ validate!
33
+ return unsupported("unsupported instrument") unless index_instrument?(@data[:meta])
34
+
35
+ ensure_option_chain!
36
+
37
+ bias = BiasAggregator.new(@data[:indicators], @config).call
38
+ # Neutral override: if higher TF trend is strong and short-term momentum aligns, allow a modest-confidence entry
39
+ bias = neutral_override(bias) if bias[:bias] == :neutral
40
+ if bias[:bias] == :neutral || bias[:confidence].to_f < @config[:min_confidence].to_f
41
+ return no_trade("neutral/low confidence")
42
+ end
43
+
44
+ side = bias[:bias] == :bullish ? :ce : :pe
45
+ moneyness = MoneynessHelper.pick_moneyness(indicators: @data[:indicators],
46
+ min_adx: @config[:min_adx_for_trend],
47
+ strong_adx: @config[:strong_adx],
48
+ bias: bias[:bias])
49
+ strike_pick = select_strike(side: side, moneyness: moneyness)
50
+ return no_trade("no liquid strikes passed filters") unless strike_pick
51
+
52
+ build_recommendation(side: side, moneyness: moneyness, bias: bias, strike_pick: strike_pick)
53
+ end
54
+
55
+ private
56
+
57
+ # If bias is neutral, try to infer a directional tilt using strong higher timeframe ADX and M5/M15 momentum
58
+ def neutral_override(bias)
59
+ ind = @data[:indicators] || {}
60
+ m60 = ind[:m60] || {}
61
+ m5 = ind[:m5] || {}
62
+ m15 = ind[:m15] || {}
63
+
64
+ adx60 = m60[:adx].to_f
65
+ strong = adx60 >= @config[:strong_adx].to_f
66
+ return bias unless strong
67
+
68
+ # Simple momentum checks
69
+ rsi5 = m5[:rsi]
70
+ rsi15 = m15[:rsi]
71
+ macd5 = (m5[:macd] || {})[:hist]
72
+ macd15 = (m15[:macd] || {})[:hist]
73
+
74
+ bullish = (rsi5 && rsi5 >= 55) || (rsi15 && rsi15 >= 55) || (macd5 && macd5 >= 0) || (macd15 && macd15 >= 0)
75
+ bearish = (rsi5 && rsi5 <= 45) || (rsi15 && rsi15 <= 45) || (macd5 && macd5 <= 0) || (macd15 && macd15 <= 0)
76
+
77
+ if bullish && !bearish
78
+ return { bias: :bullish, confidence: [@config[:min_confidence].to_f, 0.62].max, refs: %i[m5 m15 m60],
79
+ notes: ["Override: strong M60 ADX with bullish M5/M15 momentum"] }
80
+ end
81
+ if bearish && !bullish
82
+ return { bias: :bearish, confidence: [@config[:min_confidence].to_f, 0.62].max, refs: %i[m5 m15 m60],
83
+ notes: ["Override: strong M60 ADX with bearish M5/M15 momentum"] }
84
+ end
85
+
86
+ bias
87
+ end
88
+
89
+ def ensure_option_chain!
90
+ return if Array(@data[:option_chain]).any?
91
+
92
+ # Use OptionChain model: pick nearest/next expiry and fetch chain
93
+ sid = @data.dig(:meta, :security_id)
94
+ seg = @data.dig(:meta, :exchange_segment) || "IDX_I"
95
+ return unless sid && seg
96
+
97
+ expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(underlying_scrip: sid.to_i, underlying_seg: seg)
98
+ return if expiries.empty?
99
+
100
+ # Choose the nearest expiry (first element); adjust selection if API returns sorted differently
101
+ expiry = expiries.first
102
+ raw = DhanHQ::Models::OptionChain.fetch(underlying_scrip: sid.to_i, underlying_seg: seg, expiry: expiry)
103
+ oc = raw[:oc] || {}
104
+ # Transform OC structure into advisor-friendly array [{ strike:, ce: {...}, pe: {...} }]
105
+ @data[:option_chain] = oc.map do |strike, strike_data|
106
+ {
107
+ strike: strike.to_f,
108
+ ce: normalize_leg(strike_data["ce"] || {}),
109
+ pe: normalize_leg(strike_data["pe"] || {})
110
+ }
111
+ end
112
+ rescue StandardError
113
+ @data[:option_chain] ||= []
114
+ end
115
+
116
+ def normalize_leg(cepe)
117
+ {
118
+ ltp: cepe["last_price"], bid: cepe["best_bid_price"], ask: cepe["best_ask_price"],
119
+ iv: cepe["iv"], oi: cepe["oi"], volume: cepe["volume"],
120
+ delta: cepe["delta"], gamma: cepe["gamma"], vega: cepe["vega"], theta: cepe["theta"],
121
+ lot_size: cepe["lot_size"], tradable: true
122
+ }
123
+ end
124
+
125
+ def validate!
126
+ res = DhanHQ::Contracts::OptionsBuyingAdvisorContract.new.call(@data)
127
+ raise ArgumentError, res.errors.to_h.inspect unless res.success?
128
+ end
129
+
130
+ def index_instrument?(meta)
131
+ meta[:instrument].to_s == "INDEX" || meta[:exchange_segment].to_s == "IDX_I"
132
+ end
133
+
134
+ def select_strike(side:, moneyness:)
135
+ chain = Array(@data[:option_chain])
136
+ return nil if chain.empty?
137
+
138
+ target_range = @config[:preferred_deltas][side][moneyness]
139
+ best = nil
140
+ chain.each do |row|
141
+ leg = row[side]
142
+ next unless leg && leg[:tradable]
143
+ next if leg[:oi].to_i < @config[:min_oi]
144
+
145
+ spread = spread_pct(leg[:bid], leg[:ask])
146
+ next if spread.nil? || spread > @config[:max_spread_pct]
147
+
148
+ delta = leg[:delta]
149
+ next unless delta && target_range.cover?(delta)
150
+
151
+ candidate = { strike: row[:strike], leg: leg, spread: spread, oi: leg[:oi].to_i, delta: delta }
152
+ best = rank_pick(best, candidate)
153
+ end
154
+ best
155
+ end
156
+
157
+ def spread_pct(bid, ask)
158
+ return nil if bid.to_f <= 0.0 || ask.to_f <= 0.0
159
+
160
+ mid = (bid.to_f + ask.to_f) / 2.0
161
+ return nil if mid <= 0.0
162
+
163
+ ((ask.to_f - bid.to_f) / mid) * 100.0
164
+ end
165
+
166
+ def rank_pick(best, cand)
167
+ return cand unless best
168
+
169
+ target_center = cand[:delta] >= 0 ? 0.5 : -0.5
170
+ best_score = [delta_distance(best[:delta], target_center), best[:spread], -best[:oi]]
171
+ cand_score = [delta_distance(cand[:delta], target_center), cand[:spread], -cand[:oi]]
172
+ cand_score < best_score ? cand : best
173
+ end
174
+
175
+ def delta_distance(delta, center)
176
+ (delta.to_f - center).abs
177
+ end
178
+
179
+ def build_recommendation(side:, moneyness:, bias:, strike_pick:)
180
+ risk = compute_risk(strike_pick[:leg])
181
+ {
182
+ meta: { symbol: @data.dig(:meta, :symbol), security_id: @data.dig(:meta, :security_id), ts: Time.now },
183
+ decision: :enter_long,
184
+ side: side,
185
+ moneyness: moneyness,
186
+ rationale: {
187
+ bias: bias[:bias],
188
+ confidence: bias[:confidence],
189
+ notes: bias[:notes]
190
+ },
191
+ instrument: {
192
+ spot: @data[:spot],
193
+ ref_timeframes: bias[:refs],
194
+ atr_rupees_hint: atr_to_rupees
195
+ },
196
+ strike: {
197
+ recommended: strike_pick[:strike],
198
+ alternatives: [],
199
+ selection_basis: "delta window, spread, OI"
200
+ },
201
+ risk: risk
202
+ }
203
+ end
204
+
205
+ def atr_to_rupees
206
+ m15 = @data.dig(:indicators, :m15, :atr)
207
+ return nil unless m15
208
+
209
+ (m15.to_f * @config[:atr_to_rupees_factor].to_f).round(2)
210
+ end
211
+
212
+ def compute_risk(leg)
213
+ entry = leg[:ltp].to_f
214
+ sl = (entry * (1.0 - @config.dig(:risk, :sl_pct).to_f)).round(2)
215
+ tp = (entry * (1.0 + @config.dig(:risk, :tp_pct).to_f)).round(2)
216
+ {
217
+ entry_ltp: entry,
218
+ sl_ltp: sl,
219
+ tp_ltp: tp,
220
+ trail: { start_at_gain_pct: (@config.dig(:risk, :trail_arm_pct) * 100).to_i,
221
+ step_pct: (@config.dig(:risk, :trail_step_pct) * 100).to_i }
222
+ }
223
+ end
224
+
225
+ def unsupported(reason)
226
+ { decision: :no_trade, reason: reason }
227
+ end
228
+
229
+ def no_trade(reason)
230
+ { decision: :no_trade, reason: reason }
231
+ end
232
+
233
+ def deep_merge(first_hash, second_hash)
234
+ return first_hash unless second_hash
235
+
236
+ first_hash.merge(second_hash) { |_, av, bv| av.is_a?(Hash) && bv.is_a?(Hash) ? deep_merge(av, bv) : bv }
237
+ end
238
+
239
+ def deep_symbolize(obj)
240
+ case obj
241
+ when Hash
242
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
243
+ when Array
244
+ obj.map { |v| deep_symbolize(v) }
245
+ else
246
+ obj
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validation contract for options buying advisor input
8
+ class OptionsBuyingAdvisorContract < Dry::Validation::Contract
9
+ params do
10
+ required(:meta).hash do
11
+ required(:exchange_segment).filled(:string)
12
+ required(:instrument).filled(:string)
13
+ required(:security_id).filled(:string)
14
+ optional(:symbol).maybe(:string)
15
+ optional(:timestamp)
16
+ end
17
+ required(:spot).filled(:float)
18
+ required(:indicators).hash
19
+ optional(:option_chain).array(:hash)
20
+ optional(:config).hash
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/ta/candles.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module TA
6
+ # Candle data processing and resampling utilities
7
+ module Candles
8
+ module_function
9
+
10
+ def parse_time_like(val)
11
+ return Time.at(val) if val.is_a?(Numeric)
12
+
13
+ s = val.to_s
14
+ return Time.at(s.to_i) if /\A\d+\z/.match?(s) && s.length >= 10 && s.length <= 13
15
+
16
+ Time.parse(s)
17
+ end
18
+
19
+ def from_series(series)
20
+ ts = series["timestamp"] || series[:timestamp]
21
+ open = series["open"] || series[:open]
22
+ high = series["high"] || series[:high]
23
+ low = series["low"] || series[:low]
24
+ close = series["close"] || series[:close]
25
+ vol = series["volume"] || series[:volume]
26
+ return [] unless ts && open && high && low && close && vol
27
+ return [] if close.empty?
28
+
29
+ (0...close.size).map do |i|
30
+ { 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,
31
+ v: vol[i].to_f }
32
+ end
33
+ rescue StandardError
34
+ []
35
+ end
36
+
37
+ def resample(candles, minutes)
38
+ return candles if minutes.to_i == 1
39
+
40
+ grouped = {}
41
+ candles.each do |c|
42
+ key = Time.at((c[:t].to_i / 60) / minutes * minutes * 60)
43
+ b = (grouped[key] ||= { t: key, o: c[:o], h: c[:h], l: c[:l], c: c[:c], v: 0.0 })
44
+ b[:h] = [b[:h], c[:h]].max
45
+ b[:l] = [b[:l], c[:l]].min
46
+ b[:c] = c[:c]
47
+ b[:v] += c[:v]
48
+ end
49
+ grouped.keys.sort.map { |k| grouped[k] }
50
+ end
51
+ end
52
+ end
data/lib/ta/fetcher.rb ADDED
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module TA
6
+ # Historical data fetching with windowing and throttling
7
+ class Fetcher
8
+ def initialize(throttle_seconds: 3.0, max_retries: 3)
9
+ @throttle_seconds = throttle_seconds.to_f
10
+ @max_retries = max_retries.to_i
11
+ end
12
+
13
+ def intraday(params, interval)
14
+ with_retries(":intraday/#{interval}") do
15
+ DhanHQ::Models::HistoricalData.intraday(
16
+ security_id: params[:security_id],
17
+ exchange_segment: params[:exchange_segment],
18
+ instrument: params[:instrument],
19
+ interval: interval.to_s,
20
+ from_date: params[:from_date],
21
+ to_date: params[:to_date]
22
+ )
23
+ end
24
+ end
25
+
26
+ def intraday_windowed(params, interval)
27
+ from_d = Date.parse(params[:from_date])
28
+ to_d = Date.parse(params[:to_date])
29
+ max_span = 90
30
+ return intraday(params, interval) if (to_d - from_d).to_i <= max_span
31
+
32
+ merged = { "open" => [], "high" => [], "low" => [], "close" => [], "volume" => [], "timestamp" => [] }
33
+ cursor = from_d
34
+ while cursor <= to_d
35
+ chunk_to = [cursor + max_span, to_d].min
36
+ chunk_params = params.merge(from_date: cursor.strftime("%Y-%m-%d"), to_date: chunk_to.strftime("%Y-%m-%d"))
37
+ part = intraday(chunk_params, interval)
38
+ %w[open high low close volume timestamp].each do |k|
39
+ ary = (part[k] || part[k.to_sym]) || []
40
+ merged[k].concat(Array(ary))
41
+ end
42
+ cursor = chunk_to + 1
43
+ sleep_with_jitter
44
+ end
45
+ merged
46
+ end
47
+
48
+ private
49
+
50
+ def sleep_with_jitter(multiplier = 1.0)
51
+ base = @throttle_seconds * multiplier
52
+ jitter = rand * 0.3
53
+ sleep(base + jitter)
54
+ end
55
+
56
+ def with_retries(_label)
57
+ retries = 0
58
+ begin
59
+ yield
60
+ rescue DhanHQ::RateLimitError => e
61
+ retries += 1
62
+ raise e if retries > @max_retries
63
+
64
+ backoff = [5 * retries, 30].min
65
+ sleep_with_jitter(backoff / 3.0)
66
+ retry
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TA
4
+ # Technical indicator calculations with fallback implementations
5
+ module Indicators
6
+ module_function
7
+
8
+ def ema(series, period)
9
+ return nil if series.nil? || series.empty?
10
+
11
+ k = 2.0 / (period + 1)
12
+ series.each_with_index.reduce(nil) do |ema_prev, (v, i)|
13
+ i.zero? ? v.to_f : (v.to_f * k) + ((ema_prev || v.to_f) * (1 - k))
14
+ end
15
+ end
16
+
17
+ def rsi(series, period)
18
+ if defined?(RubyTechnicalAnalysis) && RubyTechnicalAnalysis.const_defined?(:RSI)
19
+ return RubyTechnicalAnalysis::RSI.new(series: series, period: period).call
20
+ end
21
+ if defined?(TechnicalAnalysis) && TechnicalAnalysis.respond_to?(:rsi)
22
+ return TechnicalAnalysis.rsi(series, period: period)
23
+ end
24
+
25
+ simple_rsi(series, period)
26
+ end
27
+
28
+ def macd(series, fast, slow, signal)
29
+ if defined?(RubyTechnicalAnalysis) && RubyTechnicalAnalysis.const_defined?(:MACD)
30
+ out = RubyTechnicalAnalysis::MACD.new(series: series, fast_period: fast, slow_period: slow,
31
+ signal_period: signal).call
32
+ if out.is_a?(Hash)
33
+ m = out[:macd]
34
+ s = out[:signal]
35
+ h = out[:histogram] || out[:hist]
36
+ m = m.last if m.is_a?(Array)
37
+ s = s.last if s.is_a?(Array)
38
+ h = h.last if h.is_a?(Array)
39
+ return { macd: m, signal: s, hist: h }
40
+ end
41
+ end
42
+ if defined?(TechnicalAnalysis) && TechnicalAnalysis.respond_to?(:macd)
43
+ out = TechnicalAnalysis.macd(series, fast: fast, slow: slow, signal: signal)
44
+ if out.is_a?(Hash)
45
+ m = out[:macd]
46
+ s = out[:signal]
47
+ h = out[:hist]
48
+ m = m.last if m.is_a?(Array)
49
+ s = s.last if s.is_a?(Array)
50
+ h = h.last if h.is_a?(Array)
51
+ return { macd: m, signal: s, hist: h }
52
+ end
53
+ end
54
+ simple_macd(series, fast, slow, signal)
55
+ end
56
+
57
+ def adx(high, low, close, period)
58
+ if defined?(RubyTechnicalAnalysis) && RubyTechnicalAnalysis.const_defined?(:ADX)
59
+ return RubyTechnicalAnalysis::ADX.new(high: high, low: low, close: close, period: period).call
60
+ end
61
+ if defined?(TechnicalAnalysis) && TechnicalAnalysis.respond_to?(:adx)
62
+ return TechnicalAnalysis.adx(high: high, low: low, close: close, period: period)
63
+ end
64
+
65
+ simple_adx(high, low, close, period)
66
+ end
67
+
68
+ def atr(high, low, close, period)
69
+ if defined?(RubyTechnicalAnalysis) && RubyTechnicalAnalysis.const_defined?(:ATR)
70
+ return RubyTechnicalAnalysis::ATR.new(high: high, low: low, close: close, period: period).call
71
+ end
72
+ if defined?(TechnicalAnalysis) && TechnicalAnalysis.respond_to?(:atr)
73
+ return TechnicalAnalysis.atr(high: high, low: low, close: close, period: period)
74
+ end
75
+
76
+ simple_atr(high, low, close, period)
77
+ end
78
+
79
+ def simple_rsi(series, period)
80
+ gains = []
81
+ losses = []
82
+ series.each_cons(2) do |a, b|
83
+ ch = b - a
84
+ gains << [ch, 0].max
85
+ losses << [(-ch), 0].max
86
+ end
87
+ avg_gain = gains.first(period).sum / period.to_f
88
+ avg_loss = losses.first(period).sum / period.to_f
89
+ rsi_vals = Array.new(series.size, nil)
90
+ gains.drop(period).each_with_index do |g, idx|
91
+ l = losses[period + idx]
92
+ avg_gain = ((avg_gain * (period - 1)) + g) / period
93
+ avg_loss = ((avg_loss * (period - 1)) + l) / period
94
+ rs = avg_loss.zero? ? 100.0 : (avg_gain / avg_loss)
95
+ rsi_vals[period + 1 + idx] = 100.0 - (100.0 / (1 + rs))
96
+ end
97
+ rsi_vals
98
+ end
99
+
100
+ def simple_macd(series, fast, slow, signal)
101
+ e_fast = ema(series, fast)
102
+ e_slow = ema(series, slow)
103
+ e_sig = ema(series, signal)
104
+ return { macd: nil, signal: nil, hist: nil } if [e_fast, e_slow, e_sig].any?(&:nil?)
105
+
106
+ macd_line = e_fast - e_slow
107
+ signal_line = e_sig
108
+ { macd: macd_line, signal: signal_line, hist: macd_line - signal_line }
109
+ end
110
+
111
+ def true_ranges(high, low, close)
112
+ trs = []
113
+ close.each_with_index do |_c, i|
114
+ if i.zero?
115
+ trs << (high[i] - low[i]).abs
116
+ else
117
+ tr = [(high[i] - low[i]).abs, (high[i] - close[i - 1]).abs, (low[i] - close[i - 1]).abs].max
118
+ trs << tr
119
+ end
120
+ end
121
+ trs
122
+ end
123
+
124
+ def simple_atr(high, low, close, period)
125
+ trs = true_ranges(high, low, close)
126
+ out = []
127
+ atr_prev = trs.first(period).sum / period.to_f
128
+ trs.each_with_index do |tr, i|
129
+ if i < period
130
+ out << nil
131
+ elsif i == period
132
+ out << atr_prev
133
+ else
134
+ atr_prev = ((atr_prev * (period - 1)) + tr) / period.to_f
135
+ out << atr_prev
136
+ end
137
+ end
138
+ out
139
+ end
140
+
141
+ def simple_adx(high, low, close, period)
142
+ plus_dm = [0]
143
+ minus_dm = [0]
144
+ (1...high.size).each do |i|
145
+ up_move = high[i] - high[i - 1]
146
+ down_move = low[i - 1] - low[i]
147
+ plus_dm << (up_move > down_move && up_move.positive? ? up_move : 0)
148
+ minus_dm << (down_move > up_move && down_move.positive? ? down_move : 0)
149
+ end
150
+ trs = true_ranges(high, low, close)
151
+ smooth_tr = trs.first(period).sum
152
+ smooth_plus_dm = plus_dm.first(period).sum
153
+ smooth_minus_dm = minus_dm.first(period).sum
154
+ adx_vals = Array.new(high.size, nil)
155
+ di_vals = []
156
+ (period...high.size).each do |i|
157
+ smooth_tr = smooth_tr - (smooth_tr / period) + trs[i]
158
+ smooth_plus_dm = smooth_plus_dm - (smooth_plus_dm / period) + plus_dm[i]
159
+ smooth_minus_dm = smooth_minus_dm - (smooth_minus_dm / period) + minus_dm[i]
160
+ plus_di = 100.0 * (smooth_plus_dm / smooth_tr)
161
+ minus_di = 100.0 * (smooth_minus_dm / smooth_tr)
162
+ dx = 100.0 * ((plus_di - minus_di).abs / (plus_di + minus_di))
163
+ di_vals << dx
164
+ adx_vals[i] = di_vals.last(period).sum / period.to_f if di_vals.size >= period
165
+ end
166
+ adx_vals
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module TA
6
+ # Market calendar utilities for trading day calculations
7
+ module MarketCalendar
8
+ MARKET_HOLIDAYS = [
9
+ Date.new(2025, 8, 15),
10
+ Date.new(2025, 10, 2),
11
+ Date.new(2025, 8, 27)
12
+ ].freeze
13
+
14
+ def self.weekday?(date)
15
+ w = date.wday
16
+ w.between?(1, 5)
17
+ end
18
+
19
+ def self.trading_day?(date)
20
+ weekday?(date) && !MARKET_HOLIDAYS.include?(date)
21
+ end
22
+
23
+ def self.last_trading_day(from: Date.today)
24
+ d = from
25
+ d -= 1 until trading_day?(d)
26
+ d
27
+ end
28
+
29
+ def self.prev_trading_day(from: Date.today)
30
+ d = from - 1
31
+ d -= 1 until trading_day?(d)
32
+ d
33
+ end
34
+
35
+ def self.today_or_last_trading_day
36
+ trading_day?(Date.today) ? Date.today : last_trading_day(from: Date.today)
37
+ end
38
+
39
+ def self.trading_days_ago(date, days)
40
+ raise ArgumentError, "days must be >= 0" if days.to_i.negative?
41
+
42
+ d = trading_day?(date) ? date : today_or_last_trading_day
43
+ count = 0
44
+ while count < days
45
+ d = prev_trading_day(from: d)
46
+ count += 1
47
+ end
48
+ d
49
+ end
50
+ end
51
+ end