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
@@ -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 & expiry lists.
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::DATA_API_PATHS.include?(path)
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
@@ -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 = 4 - (Time.now - last_request_time)
36
+ sleep_time = 3 - (Time.now - last_request_time)
37
37
  if sleep_time.positive?
38
- puts "Sleeping for #{sleep_time.round(2)} seconds due to option_chain rate limit"
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "2.1.0"
5
+ VERSION = "2.1.5"
6
6
  end
@@ -161,7 +161,7 @@ module DhanHQ
161
161
 
162
162
  private
163
163
 
164
- def prune(h) = { ExchangeSegment: h[:ExchangeSegment], SecurityId: h[:SecurityId] }
164
+ def prune(hash) = { ExchangeSegment: hash[:ExchangeSegment], SecurityId: hash[:SecurityId] }
165
165
 
166
166
  def emit(event, payload)
167
167
  begin
@@ -94,7 +94,7 @@ module DhanHQ
94
94
  backoff = 2.0
95
95
  until @stop
96
96
  failed = false
97
- got_429 = false # rubocop:disable Naming/VariableNumber
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
- begin
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}")
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|
@@ -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(&on_update)
9
- Client.new.start.on(:update, &on_update)
9
+ def self.connect(&)
10
+ Client.new.start.on(:update, &)
10
11
  end
11
12
  end
12
13
  end
@@ -3,6 +3,7 @@
3
3
  require "concurrent"
4
4
 
5
5
  module DhanHQ
6
+ # WebSocket registry for managing connections and subscriptions
6
7
  module WS
7
8
  # Tracks the set of active WebSocket clients so they can be collectively
8
9
  # disconnected when required.
@@ -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 h [Hash]
50
+ # @param hash [Hash]
51
51
  # @return [Hash] Normalized instrument hash.
52
- def self.normalize_instrument(h)
53
- seg = to_request_string(h[:ExchangeSegment] || h["ExchangeSegment"])
54
- sid = (h[:SecurityId] || h["SecurityId"]).to_s
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
 
@@ -53,7 +53,7 @@ module DhanHQ
53
53
 
54
54
  private
55
55
 
56
- def key_for(i) = "#{i[:ExchangeSegment]}:#{i[:SecurityId]}"
56
+ def key_for(instrument) = "#{instrument[:ExchangeSegment]}:#{instrument[:SecurityId]}"
57
57
  end
58
58
  end
59
59
  end
@@ -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