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
@@ -6,7 +6,7 @@ module DhanHQ
|
|
6
6
|
module Contracts
|
7
7
|
# **Validation contract for fetching option chain data**
|
8
8
|
#
|
9
|
-
# Validates request parameters for fetching option chains
|
9
|
+
# Validates request parameters for fetching option chains.
|
10
10
|
class OptionChainContract < BaseContract
|
11
11
|
params do
|
12
12
|
required(:underlying_scrip).filled(:integer) # Security ID
|
@@ -27,5 +27,15 @@ module DhanHQ
|
|
27
27
|
end
|
28
28
|
end
|
29
29
|
end
|
30
|
+
|
31
|
+
# **Validation contract for fetching option chain expiry list**
|
32
|
+
#
|
33
|
+
# Validates request parameters for fetching expiry lists (expiry not required).
|
34
|
+
class OptionChainExpiryListContract < BaseContract
|
35
|
+
params do
|
36
|
+
required(:underlying_scrip).filled(:integer) # Security ID
|
37
|
+
required(:underlying_seg).filled(:string, included_in?: %w[IDX_I NSE_FNO BSE_FNO MCX_FO])
|
38
|
+
end
|
39
|
+
end
|
30
40
|
end
|
31
41
|
end
|
@@ -26,6 +26,9 @@ module DhanHQ
|
|
26
26
|
# @param path [String] The API endpoint path.
|
27
27
|
# @return [Hash] The request headers.
|
28
28
|
def build_headers(path)
|
29
|
+
# Public CSV endpoint for segment-wise instruments requires no auth
|
30
|
+
return { "Accept" => "text/csv" } if path.start_with?("/v2/instrument/")
|
31
|
+
|
29
32
|
headers = {
|
30
33
|
"Content-Type" => "application/json",
|
31
34
|
"Accept" => "application/json",
|
@@ -43,7 +46,8 @@ module DhanHQ
|
|
43
46
|
# @param path [String] The API endpoint path.
|
44
47
|
# @return [Boolean] True if the path belongs to a DATA API.
|
45
48
|
def data_api?(path)
|
46
|
-
DhanHQ::Constants::
|
49
|
+
prefixes = DhanHQ::Constants::DATA_API_PREFIXES
|
50
|
+
prefixes.any? { |p| path.start_with?(p) }
|
47
51
|
end
|
48
52
|
|
49
53
|
# Prepares the request payload based on the HTTP method.
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../contracts/instrument_list_contract"
|
4
|
+
|
5
|
+
module DhanHQ
|
6
|
+
module Models
|
7
|
+
# Model wrapper for fetching instruments by exchange segment.
|
8
|
+
class Instrument < BaseModel
|
9
|
+
attributes :security_id, :symbol_name, :display_name, :exchange_segment, :instrument, :series,
|
10
|
+
:lot_size, :tick_size, :expiry_date, :strike_price, :option_type
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# @return [DhanHQ::Resources::Instruments]
|
14
|
+
def resource
|
15
|
+
@resource ||= DhanHQ::Resources::Instruments.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Retrieve instruments for a given segment, returning an array of models.
|
19
|
+
# @param exchange_segment [String]
|
20
|
+
# @return [Array<Instrument>]
|
21
|
+
def by_segment(exchange_segment)
|
22
|
+
validate_params!({ exchange_segment: exchange_segment }, DhanHQ::Contracts::InstrumentListContract)
|
23
|
+
|
24
|
+
csv_text = resource.by_segment(exchange_segment)
|
25
|
+
return [] unless csv_text.is_a?(String) && !csv_text.empty?
|
26
|
+
|
27
|
+
require "csv"
|
28
|
+
rows = CSV.parse(csv_text, headers: true)
|
29
|
+
rows.map { |r| new(normalize_csv_row(r), skip_validation: true) }
|
30
|
+
end
|
31
|
+
|
32
|
+
def normalize_csv_row(row)
|
33
|
+
{
|
34
|
+
security_id: row["SECURITY_ID"].to_s,
|
35
|
+
symbol_name: row["SYMBOL_NAME"],
|
36
|
+
display_name: row["DISPLAY_NAME"],
|
37
|
+
exchange_segment: row["EXCH_ID"],
|
38
|
+
instrument: row["INSTRUMENT"],
|
39
|
+
series: row["SERIES"],
|
40
|
+
lot_size: row["LOT_SIZE"]&.to_f,
|
41
|
+
tick_size: row["TICK_SIZE"]&.to_f,
|
42
|
+
expiry_date: row["SM_EXPIRY_DATE"],
|
43
|
+
strike_price: row["STRIKE_PRICE"]&.to_f,
|
44
|
+
option_type: row["OPTION_TYPE"]
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def validation_contract
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -34,6 +34,8 @@ module DhanHQ
|
|
34
34
|
# @param params [Hash] The request parameters (snake_case format)
|
35
35
|
# @return [Array<String>] The list of expiry dates
|
36
36
|
def fetch_expiry_list(params)
|
37
|
+
validate_params!(params, DhanHQ::Contracts::OptionChainExpiryListContract)
|
38
|
+
|
37
39
|
response = resource.expirylist(params)
|
38
40
|
response[:status] == "success" ? response[:data] : []
|
39
41
|
end
|
data/lib/DhanHQ/rate_limiter.rb
CHANGED
@@ -33,9 +33,11 @@ module DhanHQ
|
|
33
33
|
if @api_type == :option_chain
|
34
34
|
last_request_time = @buckets[:last_request_time]
|
35
35
|
|
36
|
-
sleep_time =
|
36
|
+
sleep_time = 3 - (Time.now - last_request_time)
|
37
37
|
if sleep_time.positive?
|
38
|
-
|
38
|
+
if ENV["DHAN_DEBUG"] == "true"
|
39
|
+
puts "Sleeping for #{sleep_time.round(2)} seconds due to option_chain rate limit"
|
40
|
+
end
|
39
41
|
sleep(sleep_time)
|
40
42
|
end
|
41
43
|
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DhanHQ
|
4
|
+
module Resources
|
5
|
+
# Resource client for fetching segment-wise instrument lists.
|
6
|
+
class Instruments < BaseAPI
|
7
|
+
# Instruments are served via the non-trading/data tier
|
8
|
+
API_TYPE = :data_api
|
9
|
+
# Base path for instruments endpoint
|
10
|
+
HTTP_PATH = "/v2/instrument"
|
11
|
+
|
12
|
+
# Fetch instruments for a given exchange segment.
|
13
|
+
# Returns CSV text; the client parses to Array<Hash> upstream if needed.
|
14
|
+
#
|
15
|
+
# @param exchange_segment [String] e.g. "NSE_EQ", "NSE_FNO", "IDX_I"
|
16
|
+
# @return [String] CSV content
|
17
|
+
def by_segment(exchange_segment)
|
18
|
+
path = "#{HTTP_PATH}/#{exchange_segment}"
|
19
|
+
resp = client.connection.get(path)
|
20
|
+
if resp.status.between?(300, 399) && resp.headers["location"]
|
21
|
+
redirect_url = resp.headers["location"]
|
22
|
+
return Faraday.get(redirect_url).body
|
23
|
+
end
|
24
|
+
resp.body
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/DhanHQ/version.rb
CHANGED
data/lib/DhanHQ/ws/client.rb
CHANGED
@@ -161,7 +161,7 @@ module DhanHQ
|
|
161
161
|
|
162
162
|
private
|
163
163
|
|
164
|
-
def prune(
|
164
|
+
def prune(hash) = { ExchangeSegment: hash[:ExchangeSegment], SecurityId: hash[:SecurityId] }
|
165
165
|
|
166
166
|
def emit(event, payload)
|
167
167
|
begin
|
data/lib/DhanHQ/ws/connection.rb
CHANGED
@@ -94,7 +94,7 @@ module DhanHQ
|
|
94
94
|
backoff = 2.0
|
95
95
|
until @stop
|
96
96
|
failed = false
|
97
|
-
got_429 = false
|
97
|
+
got_429 = false
|
98
98
|
|
99
99
|
# respect any active cool-off window
|
100
100
|
sleep (@cooloff_until - Time.now).ceil if @cooloff_until && Time.now < @cooloff_until
|
@@ -6,6 +6,7 @@ require_relative "connection"
|
|
6
6
|
module DhanHQ
|
7
7
|
module WS
|
8
8
|
module Orders
|
9
|
+
# WebSocket client for real-time order updates
|
9
10
|
class Client
|
10
11
|
def initialize(url: nil)
|
11
12
|
@callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
|
@@ -16,6 +17,7 @@ module DhanHQ
|
|
16
17
|
|
17
18
|
def start
|
18
19
|
return self if @started.true?
|
20
|
+
|
19
21
|
@started.make_true
|
20
22
|
@conn = Connection.new(url: @url) do |msg|
|
21
23
|
emit(:update, msg) if msg&.dig(:Type) == "order_alert"
|
@@ -28,6 +30,7 @@ module DhanHQ
|
|
28
30
|
|
29
31
|
def stop
|
30
32
|
return unless @started.true?
|
33
|
+
|
31
34
|
@started.make_false
|
32
35
|
@conn&.stop
|
33
36
|
emit(:close, true)
|
@@ -8,6 +8,7 @@ require "thread" # rubocop:disable Lint/RedundantRequireStatement
|
|
8
8
|
module DhanHQ
|
9
9
|
module WS
|
10
10
|
module Orders
|
11
|
+
# WebSocket connection for real-time order updates
|
11
12
|
class Connection
|
12
13
|
COOL_OFF_429 = 60
|
13
14
|
MAX_BACKOFF = 90
|
@@ -84,12 +85,10 @@ module DhanHQ
|
|
84
85
|
end
|
85
86
|
|
86
87
|
@ws.on :message do |ev|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
DhanHQ.logger&.error("[DhanHQ::WS::Orders] bad JSON #{e.class}: #{e.message}")
|
92
|
-
end
|
88
|
+
msg = JSON.parse(ev.data, symbolize_names: true)
|
89
|
+
@on_json&.call(msg)
|
90
|
+
rescue StandardError => e
|
91
|
+
DhanHQ.logger&.error("[DhanHQ::WS::Orders] bad JSON #{e.class}: #{e.message}")
|
93
92
|
end
|
94
93
|
|
95
94
|
@ws.on :close do |ev|
|
data/lib/DhanHQ/ws/orders.rb
CHANGED
@@ -4,9 +4,10 @@ require_relative "orders/client"
|
|
4
4
|
|
5
5
|
module DhanHQ
|
6
6
|
module WS
|
7
|
+
# WebSocket orders module for real-time order updates
|
7
8
|
module Orders
|
8
|
-
def self.connect(&
|
9
|
-
Client.new.start.on(:update, &
|
9
|
+
def self.connect(&)
|
10
|
+
Client.new.start.on(:update, &)
|
10
11
|
end
|
11
12
|
end
|
12
13
|
end
|
data/lib/DhanHQ/ws/registry.rb
CHANGED
data/lib/DhanHQ/ws/segments.rb
CHANGED
@@ -47,11 +47,11 @@ module DhanHQ
|
|
47
47
|
# - ExchangeSegment is a STRING enum (e.g., "NSE_FNO")
|
48
48
|
# - SecurityId is a STRING
|
49
49
|
#
|
50
|
-
# @param
|
50
|
+
# @param hash [Hash]
|
51
51
|
# @return [Hash] Normalized instrument hash.
|
52
|
-
def self.normalize_instrument(
|
53
|
-
seg = to_request_string(
|
54
|
-
sid = (
|
52
|
+
def self.normalize_instrument(hash)
|
53
|
+
seg = to_request_string(hash[:ExchangeSegment] || hash["ExchangeSegment"])
|
54
|
+
sid = (hash[:SecurityId] || hash["SecurityId"]).to_s
|
55
55
|
{ ExchangeSegment: seg, SecurityId: sid }
|
56
56
|
end
|
57
57
|
|
data/lib/DhanHQ/ws/sub_state.rb
CHANGED
@@ -44,6 +44,7 @@ require_relative "DhanHQ/resources/trades"
|
|
44
44
|
require_relative "DhanHQ/resources/historical_data"
|
45
45
|
require_relative "DhanHQ/resources/margin_calculator"
|
46
46
|
require_relative "DhanHQ/resources/market_feed"
|
47
|
+
require_relative "DhanHQ/resources/instruments"
|
47
48
|
require_relative "DhanHQ/resources/edis"
|
48
49
|
require_relative "DhanHQ/resources/kill_switch"
|
49
50
|
require_relative "DhanHQ/resources/profile"
|
@@ -56,6 +57,7 @@ require_relative "DhanHQ/models/forever_order"
|
|
56
57
|
require_relative "DhanHQ/models/super_order"
|
57
58
|
require_relative "DhanHQ/models/historical_data"
|
58
59
|
require_relative "DhanHQ/models/market_feed"
|
60
|
+
require_relative "DhanHQ/models/instrument"
|
59
61
|
require_relative "DhanHQ/models/position"
|
60
62
|
require_relative "DhanHQ/models/holding"
|
61
63
|
require_relative "DhanHQ/models/ledger_entry"
|
@@ -68,6 +70,12 @@ require_relative "DhanHQ/models/profile"
|
|
68
70
|
require_relative "DhanHQ/constants"
|
69
71
|
require_relative "DhanHQ/ws"
|
70
72
|
require_relative "DhanHQ/ws/singleton_lock"
|
73
|
+
require_relative "ta"
|
74
|
+
require_relative "dhanhq/analysis/multi_timeframe_analyzer"
|
75
|
+
require_relative "dhanhq/analysis/helpers/bias_aggregator"
|
76
|
+
require_relative "dhanhq/analysis/helpers/moneyness_helper"
|
77
|
+
require_relative "dhanhq/contracts/options_buying_advisor_contract"
|
78
|
+
require_relative "dhanhq/analysis/options_buying_advisor"
|
71
79
|
|
72
80
|
# The top-level module for the DhanHQ client library.
|
73
81
|
#
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DhanHQ
|
4
|
+
module Analysis
|
5
|
+
# Aggregates indicator scores across timeframes into a single bias and confidence score
|
6
|
+
class BiasAggregator
|
7
|
+
DEFAULT_WEIGHTS = { m1: 0.1, m5: 0.2, m15: 0.25, m25: 0.15, m60: 0.3 }.freeze
|
8
|
+
|
9
|
+
def initialize(indicators, config = {})
|
10
|
+
@indicators = indicators || {}
|
11
|
+
@weights = config[:timeframe_weights] || DEFAULT_WEIGHTS
|
12
|
+
@min_adx = (config[:min_adx_for_trend] || 22).to_f
|
13
|
+
@strong_adx = (config[:strong_adx] || 35).to_f
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
score = 0.0
|
18
|
+
wsum = 0.0
|
19
|
+
refs = []
|
20
|
+
notes = []
|
21
|
+
|
22
|
+
@weights.each do |tf, w|
|
23
|
+
next unless @indicators[tf]
|
24
|
+
|
25
|
+
s = score_tf(@indicators[tf])
|
26
|
+
next if s.nil?
|
27
|
+
|
28
|
+
score += s * w
|
29
|
+
wsum += w
|
30
|
+
refs << tf
|
31
|
+
end
|
32
|
+
|
33
|
+
avg = wsum.zero? ? 0.5 : (score / wsum)
|
34
|
+
bias = if avg > 0.55
|
35
|
+
:bullish
|
36
|
+
elsif avg < 0.45
|
37
|
+
:bearish
|
38
|
+
else
|
39
|
+
:neutral
|
40
|
+
end
|
41
|
+
|
42
|
+
{ bias: bias, confidence: avg.round(2), refs: refs, notes: notes }
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def score_tf(val)
|
48
|
+
rsi = val[:rsi]
|
49
|
+
macd = val[:macd] || {}
|
50
|
+
hist = macd[:hist]
|
51
|
+
adx = val[:adx]
|
52
|
+
|
53
|
+
rsi_component = if rsi.nil?
|
54
|
+
0.5
|
55
|
+
elsif rsi >= 55
|
56
|
+
0.65
|
57
|
+
elsif rsi <= 45
|
58
|
+
0.35
|
59
|
+
else
|
60
|
+
0.5
|
61
|
+
end
|
62
|
+
|
63
|
+
macd_component = if hist.nil?
|
64
|
+
0.5
|
65
|
+
else
|
66
|
+
hist >= 0 ? 0.6 : 0.4
|
67
|
+
end
|
68
|
+
|
69
|
+
adx_component = if adx.nil?
|
70
|
+
0.5
|
71
|
+
elsif adx >= @strong_adx
|
72
|
+
0.65
|
73
|
+
elsif adx >= @min_adx
|
74
|
+
0.55
|
75
|
+
else
|
76
|
+
0.45
|
77
|
+
end
|
78
|
+
|
79
|
+
(rsi_component * 0.4) + (macd_component * 0.3) + (adx_component * 0.3)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DhanHQ
|
4
|
+
module Analysis
|
5
|
+
# Helper module to determine the recommended moneyness (ITM/ATM/OTM) based on market conditions
|
6
|
+
module MoneynessHelper
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def pick_moneyness(indicators:, min_adx:, strong_adx:, bias: nil)
|
10
|
+
# Mark bias as intentionally observed for future rules
|
11
|
+
bias&.to_sym
|
12
|
+
|
13
|
+
m60 = indicators[:m60] || {}
|
14
|
+
adx = m60[:adx].to_f
|
15
|
+
rsi = m60[:rsi].to_f
|
16
|
+
|
17
|
+
return :atm if adx < min_adx
|
18
|
+
return :otm if adx >= strong_adx && rsi >= 60
|
19
|
+
|
20
|
+
:atm
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,232 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "dry/validation"
|
5
|
+
|
6
|
+
module DhanHQ
|
7
|
+
module Analysis
|
8
|
+
# Analyzes multi-timeframe indicator data to produce a consolidated bias summary
|
9
|
+
class MultiTimeframeAnalyzer
|
10
|
+
# Input validation contract for multi-timeframe analyzer
|
11
|
+
class InputContract < Dry::Validation::Contract
|
12
|
+
params do
|
13
|
+
required(:meta).filled(:hash)
|
14
|
+
required(:indicators).filled(:hash)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
TF_ORDER = %i[m1 m5 m15 m25 m60].freeze
|
19
|
+
|
20
|
+
def initialize(data:)
|
21
|
+
@data = symbolize(data)
|
22
|
+
end
|
23
|
+
|
24
|
+
def call
|
25
|
+
validate_data!
|
26
|
+
per_tf = compute_indicators(@data[:indicators])
|
27
|
+
aggregate_results(per_tf)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def symbolize(obj)
|
33
|
+
case obj
|
34
|
+
when Hash
|
35
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
|
36
|
+
when Array
|
37
|
+
obj.map { |v| symbolize(v) }
|
38
|
+
else
|
39
|
+
obj
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_data!
|
44
|
+
res = InputContract.new.call(@data)
|
45
|
+
raise ArgumentError, res.errors.to_h.inspect unless res.success?
|
46
|
+
end
|
47
|
+
|
48
|
+
def compute_indicators(indicators)
|
49
|
+
TF_ORDER.each_with_object({}) do |tf, out|
|
50
|
+
next unless indicators.key?(tf)
|
51
|
+
|
52
|
+
val = indicators[tf]
|
53
|
+
rsi = val[:rsi]
|
54
|
+
adx = val[:adx]
|
55
|
+
atr = val[:atr]
|
56
|
+
macd = val[:macd] || {}
|
57
|
+
macd_line = macd[:macd]
|
58
|
+
macd_signal = macd[:signal]
|
59
|
+
macd_hist = macd[:hist]
|
60
|
+
|
61
|
+
momentum = classify_rsi(rsi)
|
62
|
+
trend = classify_adx(adx)
|
63
|
+
macd_sig = classify_macd(macd_line, macd_signal, macd_hist)
|
64
|
+
vol = classify_atr(atr)
|
65
|
+
|
66
|
+
out[tf] = {
|
67
|
+
rsi: rsi, adx: adx, atr: atr, macd: macd,
|
68
|
+
momentum: momentum, trend: trend, macd_signal: macd_sig, volatility: vol,
|
69
|
+
bias: derive_bias(momentum, macd_sig)
|
70
|
+
}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def classify_rsi(rsi)
|
75
|
+
return :unknown if rsi.nil?
|
76
|
+
return :overbought if rsi >= 70
|
77
|
+
return :oversold if rsi <= 30
|
78
|
+
return :bullish if rsi >= 55
|
79
|
+
return :bearish if rsi <= 45
|
80
|
+
|
81
|
+
:neutral
|
82
|
+
end
|
83
|
+
|
84
|
+
def classify_adx(adx)
|
85
|
+
return :unknown if adx.nil?
|
86
|
+
return :strong if adx >= 25
|
87
|
+
return :weak if adx <= 15
|
88
|
+
|
89
|
+
:moderate
|
90
|
+
end
|
91
|
+
|
92
|
+
def classify_macd(macd, signal, hist)
|
93
|
+
return :unknown if macd.nil? || signal.nil?
|
94
|
+
|
95
|
+
# Treat histogram sign and distance as proxy for momentum direction
|
96
|
+
if macd > signal && (hist.nil? || hist >= 0)
|
97
|
+
:bullish
|
98
|
+
elsif macd < signal && (hist.nil? || hist <= 0)
|
99
|
+
:bearish
|
100
|
+
else
|
101
|
+
:neutral
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def classify_atr(atr)
|
106
|
+
return :unknown if atr.nil?
|
107
|
+
|
108
|
+
# ATR is relative; without baseline we can only tag as present
|
109
|
+
atr.positive? ? :expanding : :flat
|
110
|
+
end
|
111
|
+
|
112
|
+
def derive_bias(momentum, macd_sig)
|
113
|
+
return :bullish if momentum == :bullish && macd_sig == :bullish
|
114
|
+
return :bearish if momentum == :bearish && macd_sig == :bearish
|
115
|
+
|
116
|
+
:neutral
|
117
|
+
end
|
118
|
+
|
119
|
+
def aggregate_results(per_tf)
|
120
|
+
weights = { m1: 1, m5: 2, m15: 3, m25: 3, m60: 4 }
|
121
|
+
scores = { bullish: 1.0, neutral: 0.5, bearish: 0.0 }
|
122
|
+
|
123
|
+
total_w = 0.0
|
124
|
+
acc = 0.0
|
125
|
+
per_tf.each do |tf, s|
|
126
|
+
w = weights[tf] || 1
|
127
|
+
total_w += w
|
128
|
+
acc += (scores[s[:bias]] || 0.5) * w
|
129
|
+
end
|
130
|
+
avg = total_w.zero? ? 0.5 : (acc / total_w)
|
131
|
+
|
132
|
+
bias = if avg >= 0.66
|
133
|
+
:bullish
|
134
|
+
elsif avg <= 0.33
|
135
|
+
:bearish
|
136
|
+
else
|
137
|
+
:neutral
|
138
|
+
end
|
139
|
+
|
140
|
+
setup = if bias == :bullish
|
141
|
+
:buy_on_dip
|
142
|
+
elsif bias == :bearish
|
143
|
+
:sell_on_rise
|
144
|
+
else
|
145
|
+
:range_trade
|
146
|
+
end
|
147
|
+
|
148
|
+
rationale = build_rationale(per_tf)
|
149
|
+
trend_strength = build_trend_strength(per_tf)
|
150
|
+
|
151
|
+
{
|
152
|
+
meta: (@data[:meta] || {}).slice(:security_id, :instrument, :exchange_segment),
|
153
|
+
summary: {
|
154
|
+
bias: bias,
|
155
|
+
setup: setup,
|
156
|
+
confidence: avg.round(2),
|
157
|
+
rationale: rationale,
|
158
|
+
trend_strength: trend_strength
|
159
|
+
}
|
160
|
+
}
|
161
|
+
end
|
162
|
+
|
163
|
+
def build_rationale(per_tf)
|
164
|
+
{
|
165
|
+
rsi: rsi_rationale(per_tf),
|
166
|
+
macd: macd_rationale(per_tf),
|
167
|
+
adx: adx_rationale(per_tf),
|
168
|
+
atr: atr_rationale(per_tf)
|
169
|
+
}
|
170
|
+
end
|
171
|
+
|
172
|
+
def rsi_rationale(per_tf)
|
173
|
+
ups = per_tf.count { |_tf, s| %i[bullish overbought].include?(s[:momentum]) }
|
174
|
+
downs = per_tf.count { |_tf, s| %i[bearish oversold].include?(s[:momentum]) }
|
175
|
+
if ups > downs
|
176
|
+
"Upward momentum across #{ups} TFs"
|
177
|
+
elsif downs > ups
|
178
|
+
"Downward momentum across #{downs} TFs"
|
179
|
+
else
|
180
|
+
"Mixed RSI momentum"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def macd_rationale(per_tf)
|
185
|
+
ups = per_tf.count { |_tf, s| s[:macd_signal] == :bullish }
|
186
|
+
downs = per_tf.count { |_tf, s| s[:macd_signal] == :bearish }
|
187
|
+
if ups > downs
|
188
|
+
"MACD bullish signals dominant"
|
189
|
+
elsif downs > ups
|
190
|
+
"MACD bearish signals dominant"
|
191
|
+
else
|
192
|
+
"MACD mixed/neutral"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def adx_rationale(per_tf)
|
197
|
+
strong = per_tf.count { |_tf, s| s[:trend] == :strong }
|
198
|
+
return "Strong higher timeframe trend" if strong >= 2
|
199
|
+
|
200
|
+
moderate = per_tf.count { |_tf, s| s[:trend] == :moderate }
|
201
|
+
return "Moderate trend context" if moderate >= 2
|
202
|
+
|
203
|
+
"Weak/unknown trend context"
|
204
|
+
end
|
205
|
+
|
206
|
+
def atr_rationale(per_tf)
|
207
|
+
exp = per_tf.count { |_tf, s| s[:volatility] == :expanding }
|
208
|
+
exp.positive? ? "Volatility expansion" : "Low/flat volatility"
|
209
|
+
end
|
210
|
+
|
211
|
+
def build_trend_strength(per_tf)
|
212
|
+
{
|
213
|
+
short_term: summarize_bias(%i[m1 m5], per_tf),
|
214
|
+
medium_term: summarize_bias(%i[m15 m25], per_tf),
|
215
|
+
long_term: summarize_bias(%i[m60], per_tf)
|
216
|
+
}
|
217
|
+
end
|
218
|
+
|
219
|
+
def summarize_bias(tfs, per_tf)
|
220
|
+
slice = per_tf.slice(*tfs)
|
221
|
+
ups = slice.count { |_tf, s| s[:bias] == :bullish }
|
222
|
+
downs = slice.count { |_tf, s| s[:bias] == :bearish }
|
223
|
+
return :strong_bullish if ups >= 2
|
224
|
+
return :strong_bearish if downs >= 2
|
225
|
+
return :weak_bullish if ups == 1 && downs.zero?
|
226
|
+
return :weak_bearish if downs == 1 && ups.zero?
|
227
|
+
|
228
|
+
:neutral
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|