DhanHQ 2.8.0 → 3.0.1
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_todo.yml +7 -0
- data/CHANGELOG.md +19 -0
- data/README.md +16 -5
- data/docs/RAILS_WEBSOCKET_INTEGRATION.md +1 -1
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +1 -1
- data/docs/TECHNICAL_ANALYSIS.md +1 -1
- data/exe/DhanHQ +1 -1
- data/lib/DhanHQ/agent/order_preview.rb +50 -0
- data/lib/DhanHQ/agent/policy.rb +51 -0
- data/lib/DhanHQ/agent/tool_registry.rb +250 -0
- data/lib/DhanHQ/agent.rb +12 -0
- data/lib/DhanHQ/ai/context_builder.rb +145 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +114 -0
- data/lib/DhanHQ/ai.rb +27 -0
- data/lib/DhanHQ/auth.rb +0 -1
- data/lib/DhanHQ/client.rb +1 -3
- data/lib/DhanHQ/constants.rb +2 -0
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +83 -0
- data/lib/DhanHQ/contracts/twap_order_contract.rb +106 -0
- data/lib/DhanHQ/core/auth_api.rb +0 -1
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/events/base.rb +203 -0
- data/lib/DhanHQ/events/bus.rb +158 -0
- data/lib/DhanHQ/events.rb +40 -0
- data/lib/DhanHQ/indicators.rb +283 -0
- data/lib/DhanHQ/market_data/market_snapshot.rb +97 -0
- data/lib/DhanHQ/market_data/ohlc_series.rb +169 -0
- data/lib/DhanHQ/market_data/option_snapshot.rb +223 -0
- data/lib/DhanHQ/market_data.rb +25 -0
- data/lib/DhanHQ/mcp/server.rb +72 -0
- data/lib/DhanHQ/mcp.rb +10 -0
- data/lib/DhanHQ/models/funds.rb +12 -0
- data/lib/DhanHQ/models/holding.rb +42 -0
- data/lib/DhanHQ/models/iceberg_order.rb +139 -0
- data/lib/DhanHQ/models/instrument.rb +36 -0
- data/lib/DhanHQ/models/order.rb +95 -0
- data/lib/DhanHQ/models/position.rb +66 -0
- data/lib/DhanHQ/models/search_result.rb +12 -0
- data/lib/DhanHQ/models/trade.rb +13 -0
- data/lib/DhanHQ/models/twap_order.rb +136 -0
- data/lib/DhanHQ/option_analytics/black_scholes.rb +194 -0
- data/lib/DhanHQ/option_analytics/max_pain.rb +119 -0
- data/lib/DhanHQ/option_analytics.rb +36 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +61 -0
- data/lib/DhanHQ/resources/twap_orders.rb +61 -0
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +17 -0
- data/lib/DhanHQ/risk/checks/market_hours.rb +37 -0
- data/lib/DhanHQ/risk/checks/options.rb +46 -0
- data/lib/DhanHQ/risk/checks/order_type.rb +20 -0
- data/lib/DhanHQ/risk/checks/product_support.rb +34 -0
- data/lib/DhanHQ/risk/checks/quantity.rb +32 -0
- data/lib/DhanHQ/risk/checks/trading_permission.rb +16 -0
- data/lib/DhanHQ/risk/pipeline.rb +65 -0
- data/lib/DhanHQ/risk.rb +250 -0
- data/lib/DhanHQ/skills/base.rb +132 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +87 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +93 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +45 -0
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +48 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +93 -0
- data/lib/DhanHQ/skills/registry.rb +101 -0
- data/lib/DhanHQ/skills/workflow.rb +66 -0
- data/lib/DhanHQ/skills.rb +29 -0
- data/lib/DhanHQ/strategy/base.rb +189 -0
- data/lib/DhanHQ/strategy.rb +40 -0
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/decoder.rb +57 -19
- data/lib/DhanHQ.rb +3 -0
- data/lib/dhan_hq/agent.rb +3 -0
- data/lib/dhan_hq/analysis.rb +9 -0
- data/lib/dhan_hq/mcp.rb +3 -0
- data/lib/dhan_hq/ta.rb +5 -0
- data/lib/dhan_hq.rb +27 -4
- data/lib/ta/technical_analysis.rb +3 -1
- data/skills/dhanhq-ruby/SKILL.md +74 -0
- data/skills/dhanhq-ruby/references/market_data.md +3 -0
- data/skills/dhanhq-ruby/references/orders.md +7 -0
- metadata +61 -20
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Event-driven architecture for the trading system.
|
|
5
|
+
#
|
|
6
|
+
# Provides pub/sub event bus, typed events, and async helpers.
|
|
7
|
+
#
|
|
8
|
+
# @example Subscribe to events
|
|
9
|
+
# DhanHQ::Events.on(:order_filled) do |event|
|
|
10
|
+
# puts "Order filled: #{event.order_id}"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @example Emit events
|
|
14
|
+
# DhanHQ::Events.emit(:order_filled, order_id: "123", filled_quantity: 10)
|
|
15
|
+
#
|
|
16
|
+
module Events
|
|
17
|
+
# Simple pub/sub event bus.
|
|
18
|
+
#
|
|
19
|
+
# Supports subscribing to specific event types or all events.
|
|
20
|
+
class Bus
|
|
21
|
+
def initialize
|
|
22
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
23
|
+
@global_subscribers = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Subscribe to an event type.
|
|
27
|
+
#
|
|
28
|
+
# @param event_type [Symbol, Class] Event type to subscribe to
|
|
29
|
+
# @param block [Proc] Handler block
|
|
30
|
+
# @return [Integer] Subscription ID for unsubscribing
|
|
31
|
+
def on(event_type, &block)
|
|
32
|
+
@subscribers[event_type] << block
|
|
33
|
+
@subscribers[event_type].length - 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Subscribe to all events.
|
|
37
|
+
#
|
|
38
|
+
# @param block [Proc] Handler block
|
|
39
|
+
# @return [Integer] Subscription ID
|
|
40
|
+
def subscribe_all(&block)
|
|
41
|
+
@global_subscribers << block
|
|
42
|
+
@global_subscribers.length - 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Unsubscribe from an event type.
|
|
46
|
+
#
|
|
47
|
+
# @param event_type [Symbol, Class] Event type
|
|
48
|
+
# @param subscription_id [Integer] Subscription ID from on()
|
|
49
|
+
# @return [Boolean] True if unsubscribed
|
|
50
|
+
# rubocop:disable Naming/PredicateMethod
|
|
51
|
+
def off(event_type, subscription_id)
|
|
52
|
+
return false unless @subscribers[event_type]
|
|
53
|
+
|
|
54
|
+
@subscribers[event_type].delete_at(subscription_id)
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Unsubscribe from all events.
|
|
59
|
+
#
|
|
60
|
+
# @param subscription_id [Integer] Subscription ID
|
|
61
|
+
# @return [Boolean] True if unsubscribed
|
|
62
|
+
def unsubscribe_all(subscription_id)
|
|
63
|
+
@global_subscribers.delete_at(subscription_id)
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
# rubocop:enable Naming/PredicateMethod
|
|
67
|
+
|
|
68
|
+
# Emit an event to all subscribers.
|
|
69
|
+
#
|
|
70
|
+
# @param event_type [Symbol, Class] Event type
|
|
71
|
+
# @param data [Hash] Event data
|
|
72
|
+
# @return [void]
|
|
73
|
+
def emit(event_type, data = {})
|
|
74
|
+
event = build_event(event_type, data)
|
|
75
|
+
|
|
76
|
+
# Notify type-specific subscribers
|
|
77
|
+
@subscribers[event_type]&.each do |handler|
|
|
78
|
+
handler.call(event)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
DhanHQ.logger&.error("[Events] Error in handler for #{event_type}: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Notify global subscribers
|
|
84
|
+
@global_subscribers.each do |handler|
|
|
85
|
+
handler.call(event_type, event)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
DhanHQ.logger&.error("[Events] Error in global handler: #{e.message}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get count of subscribers for an event type.
|
|
92
|
+
#
|
|
93
|
+
# @param event_type [Symbol] Event type
|
|
94
|
+
# @return [Integer]
|
|
95
|
+
def subscriber_count(event_type)
|
|
96
|
+
@subscribers[event_type]&.length || 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clear all subscribers.
|
|
100
|
+
def clear
|
|
101
|
+
@subscribers.clear
|
|
102
|
+
@global_subscribers.clear
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def build_event(event_type, data)
|
|
108
|
+
case event_type
|
|
109
|
+
when :order_placed then OrderPlaced.new(data)
|
|
110
|
+
when :order_filled then OrderFilled.new(data)
|
|
111
|
+
when :order_cancelled then OrderCancelled.new(data)
|
|
112
|
+
when :order_rejected then OrderRejected.new(data)
|
|
113
|
+
when :sl_hit then SLHit.new(data)
|
|
114
|
+
when :tp_hit then TPHit.new(data)
|
|
115
|
+
when :tick_updated then TickUpdated.new(data)
|
|
116
|
+
when :position_opened then PositionOpened.new(data)
|
|
117
|
+
when :position_closed then PositionClosed.new(data)
|
|
118
|
+
when :strategy_signal then StrategySignal.new(data)
|
|
119
|
+
else
|
|
120
|
+
Base.new(data.merge(event_type: event_type))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Default bus instance
|
|
126
|
+
@bus = Bus.new
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
# Get the default event bus.
|
|
130
|
+
attr_reader :bus
|
|
131
|
+
|
|
132
|
+
# Subscribe to an event type (delegates to bus).
|
|
133
|
+
def on(event_type, &)
|
|
134
|
+
bus.on(event_type, &)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Subscribe to all events (delegates to bus).
|
|
138
|
+
def subscribe_all(&)
|
|
139
|
+
bus.subscribe_all(&)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Emit an event (delegates to bus).
|
|
143
|
+
def emit(event_type, data = {})
|
|
144
|
+
bus.emit(event_type, data)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Unsubscribe from an event type (delegates to bus).
|
|
148
|
+
def off(event_type, subscription_id)
|
|
149
|
+
bus.off(event_type, subscription_id)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Clear all subscribers (delegates to bus).
|
|
153
|
+
def clear
|
|
154
|
+
bus.clear
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "events/base"
|
|
4
|
+
require_relative "events/bus"
|
|
5
|
+
|
|
6
|
+
module DhanHQ
|
|
7
|
+
# Event-driven architecture for the trading system.
|
|
8
|
+
#
|
|
9
|
+
# Provides typed events, pub/sub event bus, and async helpers
|
|
10
|
+
# for building reactive trading applications.
|
|
11
|
+
#
|
|
12
|
+
# @example Subscribe to order events
|
|
13
|
+
# DhanHQ::Events.on(:order_filled) do |event|
|
|
14
|
+
# puts "Order filled: #{event.order_id}"
|
|
15
|
+
# puts "Filled quantity: #{event.filled_quantity}"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Emit events
|
|
19
|
+
# DhanHQ::Events.emit(:order_filled, order_id: "123", filled_quantity: 10)
|
|
20
|
+
#
|
|
21
|
+
# @example Subscribe to all events
|
|
22
|
+
# DhanHQ::Events.subscribe_all do |event_type, event|
|
|
23
|
+
# puts "Event: #{event_type} - #{event}"
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Available event types:
|
|
27
|
+
# - :order_placed - Order placed successfully
|
|
28
|
+
# - :order_filled - Order fully or partially filled
|
|
29
|
+
# - :order_cancelled - Order cancelled
|
|
30
|
+
# - :order_rejected - Order rejected by exchange
|
|
31
|
+
# - :sl_hit - Stop loss triggered
|
|
32
|
+
# - :tp_hit - Take profit triggered
|
|
33
|
+
# - :tick_updated - New market tick received
|
|
34
|
+
# - :position_opened - New position opened
|
|
35
|
+
# - :position_closed - Position closed
|
|
36
|
+
# - :strategy_signal - Strategy generated a signal
|
|
37
|
+
#
|
|
38
|
+
module Events
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Technical analysis indicators for market data analysis.
|
|
5
|
+
module Indicators
|
|
6
|
+
# Simple Moving Average (SMA)
|
|
7
|
+
#
|
|
8
|
+
# Calculates the average of a specified number of data points.
|
|
9
|
+
#
|
|
10
|
+
# @example Calculate 20-period SMA
|
|
11
|
+
# closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
|
|
12
|
+
# sma = DhanHQ::Indicators::SMA.calculate(closes, period: 5)
|
|
13
|
+
# #=> [102.2, 103.0, 103.8, 104.6, 105.4, 106.8, 108.2, 107.8]
|
|
14
|
+
#
|
|
15
|
+
class SMA
|
|
16
|
+
# Calculate SMA for the given data.
|
|
17
|
+
#
|
|
18
|
+
# @param data [Array<Numeric>] Input price data
|
|
19
|
+
# @param period [Integer] Number of periods (default: 20)
|
|
20
|
+
# @return [Array<Float>] SMA values (nil for insufficient data points)
|
|
21
|
+
def self.calculate(data, period: 20)
|
|
22
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
23
|
+
|
|
24
|
+
data.each_index.map do |i|
|
|
25
|
+
next nil if i < period - 1
|
|
26
|
+
|
|
27
|
+
window = data[(i - period + 1)..i]
|
|
28
|
+
window.sum.to_f / period
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Exponential Moving Average (EMA)
|
|
34
|
+
#
|
|
35
|
+
# Weighted average that gives more importance to recent prices.
|
|
36
|
+
#
|
|
37
|
+
# @example Calculate 12-period EMA
|
|
38
|
+
# closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
|
|
39
|
+
# ema = DhanHQ::Indicators::EMA.calculate(closes, period: 5)
|
|
40
|
+
#
|
|
41
|
+
class EMA
|
|
42
|
+
# Calculate EMA for the given data.
|
|
43
|
+
#
|
|
44
|
+
# @param data [Array<Numeric>] Input price data
|
|
45
|
+
# @param period [Integer] Number of periods (default: 20)
|
|
46
|
+
# @return [Array<Float>] EMA values (nil for insufficient data points)
|
|
47
|
+
def self.calculate(data, period: 20)
|
|
48
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
49
|
+
|
|
50
|
+
multiplier = 2.0 / (period + 1)
|
|
51
|
+
ema_values = []
|
|
52
|
+
|
|
53
|
+
data.each_with_index do |price, i|
|
|
54
|
+
if i < period - 1
|
|
55
|
+
ema_values << nil
|
|
56
|
+
elsif i == period - 1
|
|
57
|
+
# First EMA is SMA
|
|
58
|
+
window = data[0..i]
|
|
59
|
+
ema_values << (window.sum.to_f / period)
|
|
60
|
+
else
|
|
61
|
+
# EMA = (Price - Previous EMA) * Multiplier + Previous EMA
|
|
62
|
+
previous_ema = ema_values.last
|
|
63
|
+
ema_values << (((price - previous_ema) * multiplier) + previous_ema)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ema_values
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Relative Strength Index (RSI)
|
|
72
|
+
#
|
|
73
|
+
# Momentum oscillator measuring speed and magnitude of price changes.
|
|
74
|
+
#
|
|
75
|
+
# @example Calculate 14-period RSI
|
|
76
|
+
# closes = [100, 102, 101, 103, 105, 104, 106, 108, 107, 109, 111, 110, 112, 114, 113]
|
|
77
|
+
# rsi = DhanHQ::Indicators::RSI.calculate(closes, period: 14)
|
|
78
|
+
#
|
|
79
|
+
class RSI
|
|
80
|
+
# Calculate RSI for the given data.
|
|
81
|
+
#
|
|
82
|
+
# @param data [Array<Numeric>] Input price data
|
|
83
|
+
# @param period [Integer] Number of periods (default: 14)
|
|
84
|
+
# @return [Array<Float>] RSI values (nil for insufficient data points)
|
|
85
|
+
def self.calculate(data, period: 14)
|
|
86
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
87
|
+
return [] if data.length < period + 1
|
|
88
|
+
|
|
89
|
+
# Calculate price changes
|
|
90
|
+
changes = data.each_cons(2).map { |a, b| b - a }
|
|
91
|
+
|
|
92
|
+
# Calculate initial average gains and losses
|
|
93
|
+
gains = changes[0...period].select(&:positive?)
|
|
94
|
+
losses = changes[0...period].select(&:negative?).map(&:abs)
|
|
95
|
+
|
|
96
|
+
avg_gain = gains.sum.to_f / period
|
|
97
|
+
avg_loss = losses.sum.to_f / period
|
|
98
|
+
|
|
99
|
+
rsi_values = Array.new(period, nil)
|
|
100
|
+
|
|
101
|
+
# Calculate RSI for first period
|
|
102
|
+
rsi_values << calculate_rsi(avg_gain, avg_loss)
|
|
103
|
+
|
|
104
|
+
# Calculate subsequent RSI values
|
|
105
|
+
changes[period..].each do |change|
|
|
106
|
+
gain = change.positive? ? change : 0
|
|
107
|
+
loss = change.negative? ? change.abs : 0
|
|
108
|
+
|
|
109
|
+
avg_gain = ((avg_gain * (period - 1)) + gain) / period
|
|
110
|
+
avg_loss = ((avg_loss * (period - 1)) + loss) / period
|
|
111
|
+
|
|
112
|
+
rsi_values << calculate_rsi(avg_gain, avg_loss)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
rsi_values
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.calculate_rsi(avg_gain, avg_loss)
|
|
119
|
+
return 100.0 if avg_loss.zero?
|
|
120
|
+
return 0.0 if avg_gain.zero?
|
|
121
|
+
|
|
122
|
+
rs = avg_gain / avg_loss
|
|
123
|
+
100 - (100 / (1 + rs))
|
|
124
|
+
end
|
|
125
|
+
private_class_method :calculate_rsi
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Moving Average Convergence Divergence (MACD)
|
|
129
|
+
#
|
|
130
|
+
# Trend-following momentum indicator showing relationship between two EMAs.
|
|
131
|
+
#
|
|
132
|
+
# @example Calculate MACD
|
|
133
|
+
# closes = (0..99).map { |i| 100 + Math.sin(i * 0.1) * 10 }
|
|
134
|
+
# macd = DhanHQ::Indicators::MACD.calculate(closes)
|
|
135
|
+
# #=> { macd_line: [...], signal_line: [...], histogram: [...] }
|
|
136
|
+
#
|
|
137
|
+
class MACD
|
|
138
|
+
# Calculate MACD for the given data.
|
|
139
|
+
#
|
|
140
|
+
# @param data [Array<Numeric>] Input price data
|
|
141
|
+
# @param fast_period [Integer] Fast EMA period (default: 12)
|
|
142
|
+
# @param slow_period [Integer] Slow EMA period (default: 26)
|
|
143
|
+
# @param signal_period [Integer] Signal line period (default: 9)
|
|
144
|
+
# @return [Hash] Hash with :macd_line, :signal_line, and :histogram arrays
|
|
145
|
+
def self.calculate(data, fast_period: 12, slow_period: 26, signal_period: 9)
|
|
146
|
+
return { macd_line: [], signal_line: [], histogram: [] } if data.nil? || data.empty?
|
|
147
|
+
|
|
148
|
+
# Calculate fast and slow EMAs
|
|
149
|
+
fast_ema = EMA.calculate(data, period: fast_period)
|
|
150
|
+
slow_ema = EMA.calculate(data, period: slow_period)
|
|
151
|
+
|
|
152
|
+
# Calculate MACD line (fast EMA - slow EMA)
|
|
153
|
+
macd_line = fast_ema.zip(slow_ema).map do |fast, slow|
|
|
154
|
+
next nil if fast.nil? || slow.nil?
|
|
155
|
+
|
|
156
|
+
fast - slow
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Calculate signal line (EMA of MACD line)
|
|
160
|
+
valid_macd = macd_line.compact
|
|
161
|
+
signal_line_raw = valid_macd.empty? ? [] : EMA.calculate(valid_macd, period: signal_period)
|
|
162
|
+
|
|
163
|
+
# Align signal line with MACD line
|
|
164
|
+
signal_line = Array.new(macd_line.length - signal_line_raw.length, nil) + signal_line_raw
|
|
165
|
+
|
|
166
|
+
# Calculate histogram (MACD line - signal line)
|
|
167
|
+
histogram = macd_line.zip(signal_line).map do |macd, signal|
|
|
168
|
+
next nil if macd.nil? || signal.nil?
|
|
169
|
+
|
|
170
|
+
macd - signal
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
macd_line: macd_line,
|
|
175
|
+
signal_line: signal_line,
|
|
176
|
+
histogram: histogram
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Bollinger Bands
|
|
182
|
+
#
|
|
183
|
+
# Volatility indicator consisting of three lines: middle band (SMA), upper band, lower band.
|
|
184
|
+
#
|
|
185
|
+
# @example Calculate Bollinger Bands
|
|
186
|
+
# closes = (0..99).map { |i| 100 + Math.sin(i * 0.1) * 10 }
|
|
187
|
+
# bb = DhanHQ::Indicators::BollingerBands.calculate(closes)
|
|
188
|
+
# #=> { upper: [...], middle: [...], lower: [...] }
|
|
189
|
+
#
|
|
190
|
+
class BollingerBands
|
|
191
|
+
# Calculate Bollinger Bands for the given data.
|
|
192
|
+
#
|
|
193
|
+
# @param data [Array<Numeric>] Input price data
|
|
194
|
+
# @param period [Integer] Number of periods (default: 20)
|
|
195
|
+
# @param std_dev [Float] Number of standard deviations (default: 2.0)
|
|
196
|
+
# @return [Hash] Hash with :upper, :middle, and :lower arrays
|
|
197
|
+
def self.calculate(data, period: 20, std_dev: 2.0)
|
|
198
|
+
return { upper: [], middle: [], lower: [] } if data.nil? || data.empty?
|
|
199
|
+
|
|
200
|
+
# Calculate middle band (SMA)
|
|
201
|
+
middle = SMA.calculate(data, period: period)
|
|
202
|
+
|
|
203
|
+
# Calculate upper and lower bands
|
|
204
|
+
upper = []
|
|
205
|
+
lower = []
|
|
206
|
+
|
|
207
|
+
data.each_with_index do |_, i|
|
|
208
|
+
if i < period - 1
|
|
209
|
+
upper << nil
|
|
210
|
+
lower << nil
|
|
211
|
+
else
|
|
212
|
+
window = data[(i - period + 1)..i]
|
|
213
|
+
mean = middle[i]
|
|
214
|
+
variance = window.sum { |x| (x - mean)**2 } / period
|
|
215
|
+
std = Math.sqrt(variance)
|
|
216
|
+
|
|
217
|
+
upper << (mean + (std_dev * std))
|
|
218
|
+
lower << (mean - (std_dev * std))
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
upper: upper,
|
|
224
|
+
middle: middle,
|
|
225
|
+
lower: lower
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Average True Range (ATR)
|
|
231
|
+
#
|
|
232
|
+
# Volatility indicator measuring market volatility.
|
|
233
|
+
#
|
|
234
|
+
# @example Calculate ATR
|
|
235
|
+
# ohlc = [
|
|
236
|
+
# { open: 100, high: 105, low: 98, close: 103 },
|
|
237
|
+
# { open: 103, high: 108, low: 101, close: 106 },
|
|
238
|
+
# ...
|
|
239
|
+
# ]
|
|
240
|
+
# atr = DhanHQ::Indicators::ATR.calculate(ohlc, period: 14)
|
|
241
|
+
#
|
|
242
|
+
class ATR
|
|
243
|
+
# Calculate ATR for the given OHLC data.
|
|
244
|
+
#
|
|
245
|
+
# @param data [Array<Hash>] Array of OHLC hashes with :open, :high, :low, :close
|
|
246
|
+
# @param period [Integer] Number of periods (default: 14)
|
|
247
|
+
# @return [Array<Float>] ATR values (nil for insufficient data points)
|
|
248
|
+
def self.calculate(data, period: 14)
|
|
249
|
+
return [] if data.nil? || data.empty? || period < 1
|
|
250
|
+
return [] if data.length < 2
|
|
251
|
+
|
|
252
|
+
# Calculate true ranges
|
|
253
|
+
true_ranges = data.each_cons(2).map do |prev, curr|
|
|
254
|
+
high = curr[:high] || curr["high"]
|
|
255
|
+
low = curr[:low] || curr["low"]
|
|
256
|
+
prev_close = prev[:close] || prev["close"]
|
|
257
|
+
|
|
258
|
+
[high - low, (high - prev_close).abs, (low - prev_close).abs].max
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Calculate ATR using smoothed average
|
|
262
|
+
atr_values = [nil] # First value has no true range
|
|
263
|
+
|
|
264
|
+
# First ATR is simple average
|
|
265
|
+
if true_ranges.length >= period
|
|
266
|
+
first_atr = true_ranges[0...period].sum.to_f / period
|
|
267
|
+
atr_values.concat(Array.new(period - 1, nil))
|
|
268
|
+
atr_values << first_atr
|
|
269
|
+
|
|
270
|
+
# Subsequent ATRs use smoothing
|
|
271
|
+
true_ranges[period..].each do |tr|
|
|
272
|
+
previous_atr = atr_values.last
|
|
273
|
+
atr_values << (((previous_atr * (period - 1)) + tr) / period)
|
|
274
|
+
end
|
|
275
|
+
else
|
|
276
|
+
atr_values.concat(Array.new(true_ranges.length, nil))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
atr_values
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Higher-level abstractions for market data consumption.
|
|
5
|
+
module MarketData
|
|
6
|
+
# A snapshot of market data for multiple instruments at a point in time.
|
|
7
|
+
#
|
|
8
|
+
# Wraps the raw MarketFeed response into a more convenient structure
|
|
9
|
+
# with typed accessors and helper methods.
|
|
10
|
+
#
|
|
11
|
+
# @example Build a snapshot from MarketFeed response
|
|
12
|
+
# response = DhanHQ::Models::MarketFeed.ltp("NSE_EQ" => [11536, 3456])
|
|
13
|
+
# snapshot = DhanHQ::MarketData::MarketSnapshot.from_response(response)
|
|
14
|
+
# snapshot.ltp("NSE_EQ", "11536") #=> 2850.50
|
|
15
|
+
#
|
|
16
|
+
class MarketSnapshot
|
|
17
|
+
attr_reader :data, :fetched_at
|
|
18
|
+
|
|
19
|
+
def initialize(data = {})
|
|
20
|
+
@data = data
|
|
21
|
+
@fetched_at = Time.now
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Build a MarketSnapshot from a raw MarketFeed API response.
|
|
25
|
+
def self.from_response(response)
|
|
26
|
+
raw_data = response.is_a?(Hash) ? (response[:data] || response["data"] || {}) : {}
|
|
27
|
+
new(normalize_data(raw_data))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get LTP for a specific instrument.
|
|
31
|
+
def ltp(exchange_segment, security_id)
|
|
32
|
+
instrument_data(exchange_segment, security_id)&.dig(:last_price)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get OHLC for a specific instrument.
|
|
36
|
+
def ohlc(exchange_segment, security_id)
|
|
37
|
+
instrument_data(exchange_segment, security_id)&.dig(:ohlc)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get full quote (market depth) for a specific instrument.
|
|
41
|
+
def quote(exchange_segment, security_id)
|
|
42
|
+
instrument_data(exchange_segment, security_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get all instruments for a specific exchange segment.
|
|
46
|
+
def for_segment(exchange_segment)
|
|
47
|
+
@data[exchange_segment.to_s] || {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get all security IDs across all segments.
|
|
51
|
+
def security_ids
|
|
52
|
+
@data.each_with_object([]) do |(_segment, instruments), ids|
|
|
53
|
+
ids.concat(instruments.keys)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get total number of instruments in the snapshot.
|
|
58
|
+
def size
|
|
59
|
+
@data.values.sum(&:size)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if the snapshot is empty.
|
|
63
|
+
def empty?
|
|
64
|
+
@data.empty? || @data.values.all?(&:empty?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def instrument_data(exchange_segment, security_id)
|
|
70
|
+
@data[exchange_segment.to_s]&.dig(security_id.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.normalize_data(data)
|
|
74
|
+
result = {}
|
|
75
|
+
data.each do |segment, instruments|
|
|
76
|
+
result[segment.to_s] = {}
|
|
77
|
+
next unless instruments.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
instruments.each do |sec_id, info|
|
|
80
|
+
result[segment.to_s][sec_id.to_s] = normalize_instrument(info)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
private_class_method :normalize_data
|
|
86
|
+
|
|
87
|
+
def self.normalize_instrument(info)
|
|
88
|
+
return info unless info.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
info.each_with_object({}) do |(key, value), hash|
|
|
91
|
+
hash[key.to_sym] = value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
private_class_method :normalize_instrument
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|