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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +185 -0
- data/CHANGELOG.md +24 -0
- data/GUIDE.md +44 -44
- data/README.md +40 -14
- data/docs/rails_integration.md +1 -1
- data/docs/technical_analysis.md +144 -0
- data/lib/DhanHQ/config.rb +1 -0
- data/lib/DhanHQ/constants.rb +4 -6
- data/lib/DhanHQ/contracts/instrument_list_contract.rb +12 -0
- data/lib/DhanHQ/contracts/modify_order_contract.rb +1 -0
- data/lib/DhanHQ/contracts/option_chain_contract.rb +11 -1
- data/lib/DhanHQ/helpers/request_helper.rb +5 -1
- data/lib/DhanHQ/models/instrument.rb +56 -0
- data/lib/DhanHQ/models/option_chain.rb +2 -0
- data/lib/DhanHQ/rate_limiter.rb +4 -2
- data/lib/DhanHQ/resources/instruments.rb +28 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/client.rb +1 -1
- data/lib/DhanHQ/ws/connection.rb +1 -1
- data/lib/DhanHQ/ws/orders/client.rb +3 -0
- data/lib/DhanHQ/ws/orders/connection.rb +5 -6
- data/lib/DhanHQ/ws/orders.rb +3 -2
- data/lib/DhanHQ/ws/registry.rb +1 -0
- data/lib/DhanHQ/ws/segments.rb +4 -4
- data/lib/DhanHQ/ws/sub_state.rb +1 -1
- data/lib/{DhanHQ.rb → dhan_hq.rb} +8 -0
- data/lib/dhanhq/analysis/helpers/bias_aggregator.rb +83 -0
- data/lib/dhanhq/analysis/helpers/moneyness_helper.rb +24 -0
- data/lib/dhanhq/analysis/multi_timeframe_analyzer.rb +232 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +251 -0
- data/lib/dhanhq/contracts/options_buying_advisor_contract.rb +24 -0
- data/lib/ta/candles.rb +52 -0
- data/lib/ta/fetcher.rb +70 -0
- data/lib/ta/indicators.rb +169 -0
- data/lib/ta/market_calendar.rb +51 -0
- data/lib/ta/technical_analysis.rb +94 -303
- data/lib/ta.rb +7 -0
- metadata +18 -4
- data/lib/DhanHQ/ws/errors.rb +0 -0
- /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
|