DhanHQ 2.1.10 → 2.2.0
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 +1 -1
- data/.rubocop_todo.yml +143 -118
- data/CHANGELOG.md +127 -0
- data/CODE_REVIEW_ISSUES.md +397 -0
- data/FIXES_APPLIED.md +373 -0
- data/GUIDE.md +41 -0
- data/README.md +55 -0
- data/RELEASING.md +60 -0
- data/REVIEW_SUMMARY.md +120 -0
- data/VERSION_UPDATE.md +82 -0
- data/core +0 -0
- data/docs/AUTHENTICATION.md +63 -0
- data/docs/DATA_API_PARAMETERS.md +278 -0
- data/docs/PR_2.2.0.md +48 -0
- data/docs/RELEASE_GUIDE.md +492 -0
- data/docs/TESTING_GUIDE.md +1514 -0
- data/docs/live_order_updates.md +25 -1
- data/docs/rails_integration.md +29 -0
- data/docs/websocket_integration.md +22 -0
- data/lib/DhanHQ/client.rb +65 -9
- data/lib/DhanHQ/configuration.rb +26 -0
- data/lib/DhanHQ/constants.rb +1 -1
- data/lib/DhanHQ/contracts/place_order_contract.rb +51 -0
- data/lib/DhanHQ/core/base_model.rb +17 -10
- data/lib/DhanHQ/errors.rb +4 -0
- data/lib/DhanHQ/helpers/request_helper.rb +17 -2
- data/lib/DhanHQ/helpers/response_helper.rb +34 -13
- data/lib/DhanHQ/models/expired_options_data.rb +0 -6
- data/lib/DhanHQ/models/instrument_helpers.rb +4 -4
- data/lib/DhanHQ/models/order.rb +19 -2
- data/lib/DhanHQ/models/order_update.rb +0 -4
- data/lib/DhanHQ/rate_limiter.rb +40 -6
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/client.rb +11 -5
- data/lib/DhanHQ/ws/connection.rb +16 -2
- data/lib/DhanHQ/ws/market_depth/client.rb +2 -1
- data/lib/DhanHQ/ws/market_depth.rb +12 -12
- data/lib/DhanHQ/ws/orders/client.rb +76 -11
- data/lib/DhanHQ/ws/orders/connection.rb +2 -1
- metadata +18 -5
- data/lib/DhanHQ/contracts/modify_order_contract_copy.rb +0 -100
data/docs/live_order_updates.md
CHANGED
|
@@ -18,7 +18,7 @@ The DhanHQ Ruby client provides comprehensive real-time order update functionali
|
|
|
18
18
|
```ruby
|
|
19
19
|
require 'dhan_hq'
|
|
20
20
|
|
|
21
|
-
# Configure credentials
|
|
21
|
+
# Configure credentials (or use config.access_token_provider for dynamic token; see docs/AUTHENTICATION.md)
|
|
22
22
|
DhanHQ.configure do |config|
|
|
23
23
|
config.client_id = "your_client_id"
|
|
24
24
|
config.access_token = "your_access_token"
|
|
@@ -317,3 +317,27 @@ All operations are thread-safe using `Concurrent::Map` and `Concurrent::AtomicBo
|
|
|
317
317
|
- Connection state management
|
|
318
318
|
|
|
319
319
|
This ensures safe usage in multi-threaded applications.
|
|
320
|
+
|
|
321
|
+
## Testing
|
|
322
|
+
|
|
323
|
+
For comprehensive testing examples and interactive console helpers, see the [Testing Guide](TESTING_GUIDE.md). The guide includes:
|
|
324
|
+
|
|
325
|
+
- **Order Update WebSocket Testing**: Complete examples for all order update features
|
|
326
|
+
- **Event Handler Testing**: Examples for all event types
|
|
327
|
+
- **Order State Management**: Testing order tracking and querying
|
|
328
|
+
- **Interactive Console Helpers**: Load `bin/test_helpers.rb` for quick test functions
|
|
329
|
+
|
|
330
|
+
**Quick start in console:**
|
|
331
|
+
```ruby
|
|
332
|
+
# Start console
|
|
333
|
+
bin/console
|
|
334
|
+
|
|
335
|
+
# Load test helpers
|
|
336
|
+
load 'bin/test_helpers.rb'
|
|
337
|
+
|
|
338
|
+
# Test order WebSocket
|
|
339
|
+
test_order_websocket(10) # Test order updates for 10 seconds
|
|
340
|
+
|
|
341
|
+
# Monitor specific order
|
|
342
|
+
monitor_order("112111182045") # Monitor order by ID
|
|
343
|
+
```
|
data/docs/rails_integration.md
CHANGED
|
@@ -46,6 +46,11 @@ dhanhq:
|
|
|
46
46
|
ws_user_type: "SELF" # optional (SELF or PARTNER)
|
|
47
47
|
partner_id: "your-partner-id" # optional when ws_user_type: PARTNER
|
|
48
48
|
partner_secret: "your-partner-secret" # optional when ws_user_type: PARTNER
|
|
49
|
+
connect_timeout: 10 # optional, connection timeout in seconds
|
|
50
|
+
read_timeout: 30 # optional, read timeout in seconds
|
|
51
|
+
write_timeout: 30 # optional, write timeout in seconds
|
|
52
|
+
ws_max_tracked_orders: 10000 # optional, max orders to track in WebSocket
|
|
53
|
+
ws_max_order_age: 604800 # optional, max order age in seconds (7 days)
|
|
49
54
|
```
|
|
50
55
|
|
|
51
56
|
Create an initializer so your app boots with the correct configuration via
|
|
@@ -64,6 +69,11 @@ if (creds = Rails.application.credentials.dig(:dhanhq))
|
|
|
64
69
|
ENV['DHAN_WS_USER_TYPE'] ||= creds[:ws_user_type]
|
|
65
70
|
ENV['DHAN_PARTNER_ID'] ||= creds[:partner_id]
|
|
66
71
|
ENV['DHAN_PARTNER_SECRET'] ||= creds[:partner_secret]
|
|
72
|
+
ENV['DHAN_CONNECT_TIMEOUT'] ||= creds[:connect_timeout]&.to_s
|
|
73
|
+
ENV['DHAN_READ_TIMEOUT'] ||= creds[:read_timeout]&.to_s
|
|
74
|
+
ENV['DHAN_WRITE_TIMEOUT'] ||= creds[:write_timeout]&.to_s
|
|
75
|
+
ENV['DHAN_WS_MAX_TRACKED_ORDERS'] ||= creds[:ws_max_tracked_orders]&.to_s
|
|
76
|
+
ENV['DHAN_WS_MAX_ORDER_AGE'] ||= creds[:ws_max_order_age]&.to_s
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
# fall back to traditional ENV variables when credentials are not defined
|
|
@@ -93,6 +103,25 @@ or enable partner streaming flows:
|
|
|
93
103
|
Set the variables in ENV (or in credentials copied to ENV) **before** the
|
|
94
104
|
initializer calls `DhanHQ.configure_with_env`.
|
|
95
105
|
|
|
106
|
+
**Dynamic access token (optional)**
|
|
107
|
+
|
|
108
|
+
For token rotation without restarting the app (e.g. token stored in DB or refreshed via OAuth), use `access_token_provider` so the token is resolved at request time:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# config/initializers/dhanhq.rb (alternative to static ACCESS_TOKEN)
|
|
112
|
+
DhanHQ.configure do |config|
|
|
113
|
+
config.client_id = ENV["DHAN_CLIENT_ID"] || Rails.application.credentials.dig(:dhanhq, :client_id)
|
|
114
|
+
config.access_token_provider = lambda do
|
|
115
|
+
record = DhanAccessToken.active # your model or service
|
|
116
|
+
raise "Dhan token expired or missing" unless record
|
|
117
|
+
record.access_token
|
|
118
|
+
end
|
|
119
|
+
config.on_token_expired = ->(error) { DhanAccessToken.refresh! } # optional: run before retry
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
When the API returns 401 or token-expired (error code 807) and `access_token_provider` is set, the client retries the request **once** with a fresh token from the provider. `on_token_expired` is called before that retry so you can refresh your store if needed.
|
|
124
|
+
|
|
96
125
|
## 3. Build service objects for REST flows
|
|
97
126
|
|
|
98
127
|
Wrap trading actions in plain-old Ruby objects so controllers and jobs stay thin:
|
|
@@ -32,6 +32,7 @@ DhanHQ.configure do |config|
|
|
|
32
32
|
config.access_token = ENV["ACCESS_TOKEN"] || "your_access_token"
|
|
33
33
|
config.ws_user_type = ENV["DHAN_WS_USER_TYPE"] || "SELF"
|
|
34
34
|
end
|
|
35
|
+
# For dynamic token at request time (REST + WebSocket), use config.access_token_provider; see docs/AUTHENTICATION.md
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
### 2. Market Feed WebSocket (Recommended for Beginners)
|
|
@@ -894,6 +895,27 @@ puts "Market subscriptions: #{market_client.subscriptions}"
|
|
|
894
895
|
puts "Depth subscriptions: #{depth_client.subscriptions}"
|
|
895
896
|
```
|
|
896
897
|
|
|
898
|
+
## Testing
|
|
899
|
+
|
|
900
|
+
For comprehensive testing examples and interactive console helpers, see the [Testing Guide](TESTING_GUIDE.md). The guide includes:
|
|
901
|
+
|
|
902
|
+
- **WebSocket Testing**: Market feed, order updates, and market depth examples
|
|
903
|
+
- **Interactive Console Helpers**: Load `bin/test_helpers.rb` for quick test functions
|
|
904
|
+
- **Complete Examples**: Copy-paste examples for all WebSocket features
|
|
905
|
+
|
|
906
|
+
**Quick start in console:**
|
|
907
|
+
```ruby
|
|
908
|
+
# Start console
|
|
909
|
+
bin/console
|
|
910
|
+
|
|
911
|
+
# Load test helpers
|
|
912
|
+
load 'bin/test_helpers.rb'
|
|
913
|
+
|
|
914
|
+
# Test WebSocket connections
|
|
915
|
+
test_websocket(:ticker, 5) # Test market feed for 5 seconds
|
|
916
|
+
test_order_websocket(5) # Test order updates for 5 seconds
|
|
917
|
+
```
|
|
918
|
+
|
|
897
919
|
## Examples
|
|
898
920
|
|
|
899
921
|
The gem includes comprehensive examples in the `examples/` directory:
|
data/lib/DhanHQ/client.rb
CHANGED
|
@@ -36,38 +36,83 @@ module DhanHQ
|
|
|
36
36
|
#
|
|
37
37
|
# @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
|
|
38
38
|
# @return [DhanHQ::Client] A new client instance.
|
|
39
|
+
# @raise [DhanHQ::Error] If configuration is invalid or rate limiter initialization fails
|
|
39
40
|
def initialize(api_type:)
|
|
41
|
+
# Configure from ENV if CLIENT_ID is present (backward compatible behavior)
|
|
42
|
+
# Validation happens at request time in build_headers, not here
|
|
40
43
|
DhanHQ.configure_with_env if ENV.fetch("CLIENT_ID", nil)
|
|
44
|
+
|
|
41
45
|
# Use shared rate limiter instance per API type to ensure proper coordination
|
|
42
46
|
@rate_limiter = RateLimiter.for(api_type)
|
|
43
47
|
|
|
44
|
-
raise "RateLimiter initialization failed" unless @rate_limiter
|
|
48
|
+
raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
|
|
49
|
+
|
|
50
|
+
# Get timeout values from configuration or environment, with sensible defaults
|
|
51
|
+
connect_timeout = ENV.fetch("DHAN_CONNECT_TIMEOUT", 10).to_i
|
|
52
|
+
read_timeout = ENV.fetch("DHAN_READ_TIMEOUT", 30).to_i
|
|
53
|
+
write_timeout = ENV.fetch("DHAN_WRITE_TIMEOUT", 30).to_i
|
|
45
54
|
|
|
46
55
|
@connection = Faraday.new(url: DhanHQ.configuration.base_url) do |conn|
|
|
47
56
|
conn.request :json, parser_options: { symbolize_names: true }
|
|
48
57
|
conn.response :json, content_type: /\bjson$/
|
|
49
58
|
conn.response :logger if ENV["DHAN_DEBUG"] == "true"
|
|
59
|
+
conn.options.timeout = read_timeout
|
|
60
|
+
conn.options.open_timeout = connect_timeout
|
|
61
|
+
conn.options.write_timeout = write_timeout
|
|
50
62
|
conn.adapter Faraday.default_adapter
|
|
51
63
|
end
|
|
52
64
|
end
|
|
53
65
|
|
|
54
|
-
# Sends an HTTP request to the API.
|
|
66
|
+
# Sends an HTTP request to the API with automatic retry for transient errors.
|
|
55
67
|
#
|
|
56
68
|
# @param method [Symbol] The HTTP method (`:get`, `:post`, `:put`, `:delete`)
|
|
57
69
|
# @param path [String] The API endpoint path.
|
|
58
70
|
# @param payload [Hash] The request parameters or body.
|
|
71
|
+
# @param retries [Integer] Number of retries for transient errors (default: 3)
|
|
59
72
|
# @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] Parsed JSON response.
|
|
60
73
|
# @raise [DhanHQ::Error] If an HTTP error occurs.
|
|
61
|
-
def request(method, path, payload)
|
|
74
|
+
def request(method, path, payload, retries: 3)
|
|
62
75
|
@rate_limiter.throttle! # **Ensure we don't hit rate limit before calling API**
|
|
63
76
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
attempt = 0
|
|
78
|
+
auth_retry_done = false
|
|
79
|
+
begin
|
|
80
|
+
response = connection.send(method) do |req|
|
|
81
|
+
req.url path
|
|
82
|
+
req.headers.merge!(build_headers(path))
|
|
83
|
+
prepare_payload(req, payload, method)
|
|
84
|
+
end
|
|
69
85
|
|
|
70
|
-
|
|
86
|
+
handle_response(response)
|
|
87
|
+
rescue DhanHQ::InvalidAuthenticationError, DhanHQ::InvalidTokenError,
|
|
88
|
+
DhanHQ::TokenExpiredError, DhanHQ::AuthenticationFailedError => e
|
|
89
|
+
config = DhanHQ.configuration
|
|
90
|
+
if !auth_retry_done && config&.access_token_provider
|
|
91
|
+
auth_retry_done = true
|
|
92
|
+
config.on_token_expired&.call(e)
|
|
93
|
+
DhanHQ.logger&.warn("[DhanHQ::Client] Auth failure (#{e.class}), retrying once with fresh token")
|
|
94
|
+
retry
|
|
95
|
+
end
|
|
96
|
+
raise
|
|
97
|
+
rescue DhanHQ::RateLimitError, DhanHQ::InternalServerError, DhanHQ::NetworkError => e
|
|
98
|
+
attempt += 1
|
|
99
|
+
if attempt <= retries
|
|
100
|
+
backoff_time = calculate_backoff(attempt)
|
|
101
|
+
DhanHQ.logger&.warn("[DhanHQ::Client] Transient error (#{e.class}), retrying in #{backoff_time}s (attempt #{attempt}/#{retries})")
|
|
102
|
+
sleep(backoff_time)
|
|
103
|
+
retry
|
|
104
|
+
end
|
|
105
|
+
raise
|
|
106
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
|
|
107
|
+
attempt += 1
|
|
108
|
+
if attempt <= retries
|
|
109
|
+
backoff_time = calculate_backoff(attempt)
|
|
110
|
+
DhanHQ.logger&.warn("[DhanHQ::Client] Network error (#{e.class}), retrying in #{backoff_time}s (attempt #{attempt}/#{retries})")
|
|
111
|
+
sleep(backoff_time)
|
|
112
|
+
retry
|
|
113
|
+
end
|
|
114
|
+
raise DhanHQ::NetworkError, "Request failed after #{retries} retries: #{e.message}"
|
|
115
|
+
end
|
|
71
116
|
end
|
|
72
117
|
|
|
73
118
|
# Convenience wrapper for issuing a GET request.
|
|
@@ -113,5 +158,16 @@ module DhanHQ
|
|
|
113
158
|
def delete(path, params = {})
|
|
114
159
|
request(:delete, path, params)
|
|
115
160
|
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Calculates exponential backoff time
|
|
165
|
+
#
|
|
166
|
+
# @param attempt [Integer] Current attempt number (1-based)
|
|
167
|
+
# @return [Float] Backoff time in seconds
|
|
168
|
+
def calculate_backoff(attempt)
|
|
169
|
+
# Exponential backoff: 1s, 2s, 4s, 8s, etc., capped at 30s
|
|
170
|
+
[2**(attempt - 1), 30].min.to_f
|
|
171
|
+
end
|
|
116
172
|
end
|
|
117
173
|
end
|
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -20,6 +20,17 @@ module DhanHQ
|
|
|
20
20
|
# @return [String, nil] The access token or `nil` if not set.
|
|
21
21
|
attr_accessor :access_token
|
|
22
22
|
|
|
23
|
+
# Optional callable (Proc/lambda) that returns the access token at request time.
|
|
24
|
+
# When set, {#resolved_access_token} calls this instead of using {#access_token}.
|
|
25
|
+
# @return [Proc, nil]
|
|
26
|
+
attr_accessor :access_token_provider
|
|
27
|
+
|
|
28
|
+
# Optional callable invoked when the API returns 401/token-expired and the client
|
|
29
|
+
# is about to retry (when {#access_token_provider} is set). Use for logging or
|
|
30
|
+
# refreshing token in your store before the retry fetches a new token.
|
|
31
|
+
# @return [Proc, nil]
|
|
32
|
+
attr_accessor :on_token_expired
|
|
33
|
+
|
|
23
34
|
# The base URL for API requests.
|
|
24
35
|
# @return [String] The base URL for the DhanHQ API.
|
|
25
36
|
attr_accessor :base_url
|
|
@@ -64,6 +75,21 @@ module DhanHQ
|
|
|
64
75
|
# @return [String, nil]
|
|
65
76
|
attr_accessor :partner_secret
|
|
66
77
|
|
|
78
|
+
# Returns the access token to use for this request.
|
|
79
|
+
# If {#access_token_provider} is set, calls it (no memoization; token per request).
|
|
80
|
+
# Otherwise returns {#access_token}.
|
|
81
|
+
# @return [String]
|
|
82
|
+
# @raise [DhanHQ::AuthenticationError] if provider returns nil/empty or no token is set.
|
|
83
|
+
def resolved_access_token
|
|
84
|
+
if access_token_provider
|
|
85
|
+
token = access_token_provider.call
|
|
86
|
+
raise DhanHQ::AuthenticationError, "access_token_provider returned nil or empty" if token.nil? || token.to_s.empty?
|
|
87
|
+
token.to_s
|
|
88
|
+
else
|
|
89
|
+
access_token
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
67
93
|
# Initializes a new configuration instance with default values.
|
|
68
94
|
#
|
|
69
95
|
# @example
|
data/lib/DhanHQ/constants.rb
CHANGED
|
@@ -171,7 +171,7 @@ module DhanHQ
|
|
|
171
171
|
"804" => DhanHQ::Error, # Too many instruments
|
|
172
172
|
"805" => DhanHQ::RateLimitError, # Too many requests
|
|
173
173
|
"806" => DhanHQ::DataError, # Data API not subscribed
|
|
174
|
-
"807" => DhanHQ::
|
|
174
|
+
"807" => DhanHQ::TokenExpiredError, # Token expired
|
|
175
175
|
"808" => DhanHQ::AuthenticationFailedError, # Auth failed
|
|
176
176
|
"809" => DhanHQ::InvalidTokenError, # Invalid token
|
|
177
177
|
"810" => DhanHQ::InvalidClientIDError, # Invalid Client ID
|
|
@@ -93,6 +93,57 @@ module DhanHQ
|
|
|
93
93
|
optional(:drv_strike_price).maybe(:float, gt?: 0)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# Validate that float values are finite (not NaN or Infinity) and within reasonable bounds
|
|
97
|
+
rule(:price) do
|
|
98
|
+
if values[:price].is_a?(Float)
|
|
99
|
+
if values[:price].nan? || values[:price].infinite?
|
|
100
|
+
key(:price).failure("must be a finite number")
|
|
101
|
+
elsif values[:price] > 1_000_000_000
|
|
102
|
+
key(:price).failure("must be less than 1,000,000,000")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
rule(:trigger_price) do
|
|
108
|
+
if values[:trigger_price].is_a?(Float)
|
|
109
|
+
if values[:trigger_price].nan? || values[:trigger_price].infinite?
|
|
110
|
+
key(:trigger_price).failure("must be a finite number")
|
|
111
|
+
elsif values[:trigger_price] > 1_000_000_000
|
|
112
|
+
key(:trigger_price).failure("must be less than 1,000,000,000")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
rule(:bo_profit_value) do
|
|
118
|
+
if values[:bo_profit_value].is_a?(Float)
|
|
119
|
+
if values[:bo_profit_value].nan? || values[:bo_profit_value].infinite?
|
|
120
|
+
key(:bo_profit_value).failure("must be a finite number")
|
|
121
|
+
elsif values[:bo_profit_value] > 1_000_000_000
|
|
122
|
+
key(:bo_profit_value).failure("must be less than 1,000,000,000")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
rule(:bo_stop_loss_value) do
|
|
128
|
+
if values[:bo_stop_loss_value].is_a?(Float)
|
|
129
|
+
if values[:bo_stop_loss_value].nan? || values[:bo_stop_loss_value].infinite?
|
|
130
|
+
key(:bo_stop_loss_value).failure("must be a finite number")
|
|
131
|
+
elsif values[:bo_stop_loss_value] > 1_000_000_000
|
|
132
|
+
key(:bo_stop_loss_value).failure("must be less than 1,000,000,000")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
rule(:drv_strike_price) do
|
|
138
|
+
if values[:drv_strike_price].is_a?(Float)
|
|
139
|
+
if values[:drv_strike_price].nan? || values[:drv_strike_price].infinite?
|
|
140
|
+
key(:drv_strike_price).failure("must be a finite number")
|
|
141
|
+
elsif values[:drv_strike_price] > 1_000_000_000
|
|
142
|
+
key(:drv_strike_price).failure("must be less than 1,000,000,000")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
96
147
|
# Custom validation for trigger price when the order type is STOP_LOSS or STOP_LOSS_MARKET.
|
|
97
148
|
rule(:trigger_price, :order_type) do
|
|
98
149
|
if values[:order_type] =~ /^STOP_LOSS/ && !values[:trigger_price]
|
|
@@ -143,10 +143,13 @@ module DhanHQ
|
|
|
143
143
|
# @return [Array<BaseModel>]
|
|
144
144
|
def parse_collection_response(response)
|
|
145
145
|
# Some endpoints return arrays, others might return a `[:data]` structure
|
|
146
|
-
|
|
146
|
+
unless response.is_a?(Array) || (response.is_a?(Hash) && response[:data].is_a?(Array))
|
|
147
|
+
DhanHQ.logger&.warn("[DhanHQ::BaseModel] Unexpected response format for collection: #{response.class}. Expected Array or Hash with :data key.")
|
|
148
|
+
return []
|
|
149
|
+
end
|
|
147
150
|
|
|
148
151
|
collection = response.is_a?(Array) ? response : response[:data]
|
|
149
|
-
collection.map { |record| new(record) }
|
|
152
|
+
collection.map { |record| new(record, skip_validation: true) }
|
|
150
153
|
end
|
|
151
154
|
end
|
|
152
155
|
|
|
@@ -155,11 +158,15 @@ module DhanHQ
|
|
|
155
158
|
# Update an existing resource
|
|
156
159
|
#
|
|
157
160
|
# @param attributes [Hash] Attributes to update
|
|
158
|
-
# @return [DhanHQ::BaseModel, DhanHQ::ErrorObject]
|
|
161
|
+
# @return [DhanHQ::BaseModel, DhanHQ::ErrorObject] Updated model instance or ErrorObject on failure
|
|
159
162
|
def update(attributes = {})
|
|
160
163
|
response = self.class.resource.put("/#{id}", params: attributes)
|
|
161
164
|
|
|
162
|
-
success_response?(response)
|
|
165
|
+
if success_response?(response)
|
|
166
|
+
self.class.build_from_response(response)
|
|
167
|
+
else
|
|
168
|
+
DhanHQ::ErrorObject.new(response)
|
|
169
|
+
end
|
|
163
170
|
end
|
|
164
171
|
|
|
165
172
|
# Persists the current resource by delegating to {#create} or {#update}.
|
|
@@ -196,10 +203,7 @@ module DhanHQ
|
|
|
196
203
|
#
|
|
197
204
|
# @return [Boolean] True when the server confirms deletion.
|
|
198
205
|
def delete
|
|
199
|
-
|
|
200
|
-
success_response?(response)
|
|
201
|
-
rescue StandardError
|
|
202
|
-
false
|
|
206
|
+
destroy
|
|
203
207
|
end
|
|
204
208
|
|
|
205
209
|
# Alias for {#delete} maintained for ActiveModel familiarity.
|
|
@@ -208,7 +212,8 @@ module DhanHQ
|
|
|
208
212
|
def destroy
|
|
209
213
|
response = self.class.resource.delete("/#{id}")
|
|
210
214
|
success_response?(response)
|
|
211
|
-
rescue StandardError
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
DhanHQ.logger&.error("[DhanHQ::BaseModel] Error deleting resource #{id}: #{e.class} #{e.message}")
|
|
212
217
|
false
|
|
213
218
|
end
|
|
214
219
|
|
|
@@ -239,7 +244,9 @@ module DhanHQ
|
|
|
239
244
|
#
|
|
240
245
|
# @return [String, Integer, nil]
|
|
241
246
|
def id
|
|
242
|
-
@attributes[:id] || @attributes[:order_id] || @attributes[:security_id]
|
|
247
|
+
id_value = @attributes[:id] || @attributes[:order_id] || @attributes[:security_id]
|
|
248
|
+
# Convert to string for consistency, but preserve nil
|
|
249
|
+
id_value&.to_s
|
|
243
250
|
end
|
|
244
251
|
|
|
245
252
|
# Dynamically assign attributes as methods
|
data/lib/DhanHQ/errors.rb
CHANGED
|
@@ -5,6 +5,8 @@ module DhanHQ
|
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
7
|
# Authentication and access errors
|
|
8
|
+
# Raised when access token cannot be resolved (missing config or provider returned nil).
|
|
9
|
+
class AuthenticationError < Error; end
|
|
8
10
|
# DH-901
|
|
9
11
|
class InvalidAuthenticationError < Error; end
|
|
10
12
|
# DH-902
|
|
@@ -13,6 +15,8 @@ module DhanHQ
|
|
|
13
15
|
class UserAccountError < Error; end
|
|
14
16
|
# DH-808
|
|
15
17
|
class AuthenticationFailedError < Error; end
|
|
18
|
+
# DH-807: token expired (detected from API response; use with retry-on-401)
|
|
19
|
+
class TokenExpiredError < Error; end
|
|
16
20
|
# DH-807, DH-809
|
|
17
21
|
class InvalidTokenError < Error; end
|
|
18
22
|
# DH-810
|
|
@@ -25,22 +25,37 @@ module DhanHQ
|
|
|
25
25
|
#
|
|
26
26
|
# @param path [String] The API endpoint path.
|
|
27
27
|
# @return [Hash] The request headers.
|
|
28
|
+
# @raise [DhanHQ::InvalidAuthenticationError] If required headers are missing
|
|
28
29
|
def build_headers(path)
|
|
29
30
|
# Public CSV endpoint for segment-wise instruments requires no auth
|
|
30
31
|
return { "Accept" => "text/csv" } if path.start_with?("/v2/instrument/")
|
|
31
32
|
|
|
33
|
+
token = resolved_access_token
|
|
34
|
+
raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
|
|
35
|
+
|
|
32
36
|
headers = {
|
|
33
37
|
"Content-Type" => "application/json",
|
|
34
38
|
"Accept" => "application/json",
|
|
35
|
-
"access-token" =>
|
|
39
|
+
"access-token" => token
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
# Add client-id for DATA APIs
|
|
39
|
-
|
|
43
|
+
if data_api?(path)
|
|
44
|
+
client_id = DhanHQ.configuration&.client_id
|
|
45
|
+
unless client_id
|
|
46
|
+
raise DhanHQ::InvalidAuthenticationError,
|
|
47
|
+
"client_id is required for DATA APIs but not set. Please configure DhanHQ with CLIENT_ID."
|
|
48
|
+
end
|
|
49
|
+
headers["client-id"] = client_id
|
|
50
|
+
end
|
|
40
51
|
|
|
41
52
|
headers
|
|
42
53
|
end
|
|
43
54
|
|
|
55
|
+
def resolved_access_token
|
|
56
|
+
DhanHQ.configuration&.resolved_access_token
|
|
57
|
+
end
|
|
58
|
+
|
|
44
59
|
# Determines if the API path requires a `client-id` header.
|
|
45
60
|
#
|
|
46
61
|
# @param path [String] The API endpoint path.
|
|
@@ -29,7 +29,12 @@ module DhanHQ
|
|
|
29
29
|
# @raise [DhanHQ::Error] If an HTTP error occurs.
|
|
30
30
|
def handle_response(response)
|
|
31
31
|
case response.status
|
|
32
|
-
when 200..
|
|
32
|
+
when 200..201 then parse_json(response.body)
|
|
33
|
+
when 202
|
|
34
|
+
# 202 Accepted is used for async operations (e.g., position conversion)
|
|
35
|
+
# Return status hash to indicate success for async operations
|
|
36
|
+
{ status: "accepted" }.with_indifferent_access
|
|
37
|
+
when 203..299 then parse_json(response.body)
|
|
33
38
|
else handle_error(response)
|
|
34
39
|
end
|
|
35
40
|
end
|
|
@@ -49,16 +54,21 @@ module DhanHQ
|
|
|
49
54
|
|
|
50
55
|
error_class = DhanHQ::Constants::DHAN_ERROR_MAPPING[error_code]
|
|
51
56
|
|
|
52
|
-
error_class
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
unless error_class
|
|
58
|
+
# Log unmapped error codes for investigation
|
|
59
|
+
DhanHQ.logger&.warn("[DhanHQ] Unmapped error code: #{error_code} (status: #{response.status})")
|
|
60
|
+
|
|
61
|
+
error_class =
|
|
62
|
+
case response.status
|
|
63
|
+
when 400 then DhanHQ::InputExceptionError
|
|
64
|
+
when 401 then DhanHQ::InvalidAuthenticationError
|
|
65
|
+
when 403 then DhanHQ::InvalidAccessError
|
|
66
|
+
when 404 then DhanHQ::NotFoundError
|
|
67
|
+
when 429 then DhanHQ::RateLimitError
|
|
68
|
+
when 500..599 then DhanHQ::InternalServerError
|
|
69
|
+
else DhanHQ::OtherError
|
|
70
|
+
end
|
|
71
|
+
end
|
|
62
72
|
|
|
63
73
|
error_text =
|
|
64
74
|
if error_code == "DH-1111"
|
|
@@ -74,13 +84,24 @@ module DhanHQ
|
|
|
74
84
|
#
|
|
75
85
|
# @param body [String, Hash] The response body.
|
|
76
86
|
# @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] The parsed JSON.
|
|
87
|
+
# @raise [DhanHQ::DataError] If JSON parsing fails (only for truly invalid JSON, not empty responses)
|
|
77
88
|
def parse_json(body)
|
|
78
89
|
parsed_body =
|
|
79
90
|
if body.is_a?(String)
|
|
91
|
+
# Handle empty strings gracefully (backward compatible)
|
|
92
|
+
return {}.with_indifferent_access if body.strip.empty?
|
|
93
|
+
|
|
80
94
|
begin
|
|
81
95
|
JSON.parse(body, symbolize_names: true)
|
|
82
|
-
rescue JSON::ParserError
|
|
83
|
-
|
|
96
|
+
rescue JSON::ParserError => e
|
|
97
|
+
# Log error but maintain backward compatibility for edge cases
|
|
98
|
+
# Only raise for clearly malformed JSON, not for empty/whitespace responses
|
|
99
|
+
DhanHQ.logger&.error("[DhanHQ] JSON parse error: #{e.message}")
|
|
100
|
+
DhanHQ.logger&.debug("[DhanHQ] Failed to parse body (first 200 chars): #{body[0..200]}")
|
|
101
|
+
|
|
102
|
+
# Raise DataError for invalid JSON (this is an improvement, not a breaking change)
|
|
103
|
+
# The API should never return invalid JSON, so this helps catch API issues
|
|
104
|
+
raise DhanHQ::DataError, "Failed to parse JSON response: #{e.message}"
|
|
84
105
|
end
|
|
85
106
|
else
|
|
86
107
|
body
|
|
@@ -42,7 +42,6 @@ module DhanHQ
|
|
|
42
42
|
# call_data = data.call_data
|
|
43
43
|
# put_data = data.put_data
|
|
44
44
|
#
|
|
45
|
-
# rubocop:disable Metrics/ClassLength
|
|
46
45
|
class ExpiredOptionsData < BaseModel
|
|
47
46
|
# All expired options data attributes
|
|
48
47
|
attributes :exchange_segment, :interval, :security_id, :instrument,
|
|
@@ -257,7 +256,6 @@ module DhanHQ
|
|
|
257
256
|
# - **:low** [Array<Float>] Low prices for each time point
|
|
258
257
|
# - **:close** [Array<Float>] Close prices for each time point
|
|
259
258
|
# @return [Hash{Symbol => Array}] Empty hash if option data is not available
|
|
260
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
261
259
|
def ohlc_data(option_type = nil)
|
|
262
260
|
option_type ||= drv_option_type
|
|
263
261
|
option_data = data_for_type(option_type)
|
|
@@ -270,7 +268,6 @@ module DhanHQ
|
|
|
270
268
|
close: option_data["close"] || option_data[:close] || []
|
|
271
269
|
}
|
|
272
270
|
end
|
|
273
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
274
271
|
|
|
275
272
|
##
|
|
276
273
|
# Gets volume data for the specified option type.
|
|
@@ -444,7 +441,6 @@ module DhanHQ
|
|
|
444
441
|
# - **:has_volume** [Boolean] Whether volume data is available
|
|
445
442
|
# - **:has_open_interest** [Boolean] Whether open interest data is available
|
|
446
443
|
# - **:has_implied_volatility** [Boolean] Whether implied volatility data is available
|
|
447
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
448
444
|
def summary_stats(option_type = nil)
|
|
449
445
|
option_type ||= drv_option_type
|
|
450
446
|
ohlc = ohlc_data(option_type)
|
|
@@ -464,7 +460,6 @@ module DhanHQ
|
|
|
464
460
|
has_implied_volatility: !iv_data.empty?
|
|
465
461
|
}
|
|
466
462
|
end
|
|
467
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
468
463
|
|
|
469
464
|
##
|
|
470
465
|
# Checks if this is index options data.
|
|
@@ -551,6 +546,5 @@ module DhanHQ
|
|
|
551
546
|
sign * offset
|
|
552
547
|
end
|
|
553
548
|
end
|
|
554
|
-
# rubocop:enable Metrics/ClassLength
|
|
555
549
|
end
|
|
556
550
|
end
|
|
@@ -64,8 +64,8 @@ module DhanHQ
|
|
|
64
64
|
# @example
|
|
65
65
|
# instrument = DhanHQ::Models::Instrument.find("NSE_EQ", "RELIANCE")
|
|
66
66
|
# instrument.daily(from_date: "2024-01-01", to_date: "2024-01-31")
|
|
67
|
-
def daily(from_date:, to_date:, **
|
|
68
|
-
params = build_historical_data_params(from_date: from_date, to_date: to_date, **
|
|
67
|
+
def daily(from_date:, to_date:, **)
|
|
68
|
+
params = build_historical_data_params(from_date: from_date, to_date: to_date, **)
|
|
69
69
|
DhanHQ::Models::HistoricalData.daily(params)
|
|
70
70
|
end
|
|
71
71
|
|
|
@@ -80,8 +80,8 @@ module DhanHQ
|
|
|
80
80
|
# @example
|
|
81
81
|
# instrument = DhanHQ::Models::Instrument.find("IDX_I", "NIFTY")
|
|
82
82
|
# instrument.intraday(from_date: "2024-09-11", to_date: "2024-09-15", interval: "15")
|
|
83
|
-
def intraday(from_date:, to_date:, interval:, **
|
|
84
|
-
params = build_historical_data_params(from_date: from_date, to_date: to_date, interval: interval, **
|
|
83
|
+
def intraday(from_date:, to_date:, interval:, **)
|
|
84
|
+
params = build_historical_data_params(from_date: from_date, to_date: to_date, interval: interval, **)
|
|
85
85
|
DhanHQ::Models::HistoricalData.intraday(params)
|
|
86
86
|
end
|
|
87
87
|
|
data/lib/DhanHQ/models/order.rb
CHANGED
|
@@ -366,6 +366,12 @@ module DhanHQ
|
|
|
366
366
|
def modify(new_params)
|
|
367
367
|
raise "Order ID is required to modify an order" unless id
|
|
368
368
|
|
|
369
|
+
# Log warning for invalid states but still attempt API call (let API handle validation)
|
|
370
|
+
# This maintains backward compatibility - API will return appropriate error
|
|
371
|
+
if order_status && %w[TRADED CANCELLED EXPIRED CLOSED].include?(order_status)
|
|
372
|
+
DhanHQ.logger&.warn("[DhanHQ::Models::Order] Attempting to modify order #{id} in #{order_status} state - API will reject")
|
|
373
|
+
end
|
|
374
|
+
|
|
369
375
|
base_payload = attributes.merge(new_params)
|
|
370
376
|
normalized_payload = snake_case(base_payload).merge(order_id: id)
|
|
371
377
|
filtered_payload = normalized_payload.each_with_object({}) do |(key, value), memo|
|
|
@@ -373,7 +379,7 @@ module DhanHQ
|
|
|
373
379
|
memo[symbolized_key] = value if MODIFIABLE_FIELDS.include?(symbolized_key)
|
|
374
380
|
end
|
|
375
381
|
filtered_payload[:order_id] ||= id
|
|
376
|
-
filtered_payload[:dhan_client_id] ||= attributes[:dhan_client_id]
|
|
382
|
+
filtered_payload[:dhan_client_id] ||= attributes[:dhan_client_id] || DhanHQ.configuration&.client_id
|
|
377
383
|
|
|
378
384
|
cleaned_payload = filtered_payload.compact
|
|
379
385
|
formatted_payload = camelize_keys(cleaned_payload)
|
|
@@ -490,23 +496,34 @@ module DhanHQ
|
|
|
490
496
|
|
|
491
497
|
if new_record?
|
|
492
498
|
# PLACE ORDER
|
|
499
|
+
DhanHQ.logger&.info("[DhanHQ::Models::Order] Placing order: #{attributes.slice(:transaction_type,
|
|
500
|
+
:exchange_segment, :security_id, :quantity, :price).inspect}")
|
|
493
501
|
response = self.class.resource.create(to_request_params)
|
|
494
502
|
if success_response?(response) && response["orderId"]
|
|
495
503
|
@attributes.merge!(normalize_keys(response))
|
|
496
504
|
assign_attributes
|
|
505
|
+
DhanHQ.logger&.info("[DhanHQ::Models::Order] Order placed successfully: #{response["orderId"]}")
|
|
497
506
|
true
|
|
498
507
|
else
|
|
499
|
-
|
|
508
|
+
error_msg = response.is_a?(Hash) ? response[:errorMessage] || response[:message] || "Unknown error" : "Invalid response format"
|
|
509
|
+
DhanHQ.logger&.error("[DhanHQ::Models::Order] Order placement failed: #{error_msg}")
|
|
510
|
+
@errors = response if response.is_a?(Hash)
|
|
500
511
|
false
|
|
501
512
|
end
|
|
502
513
|
else
|
|
503
514
|
# MODIFY ORDER
|
|
515
|
+
DhanHQ.logger&.info("[DhanHQ::Models::Order] Modifying order #{id}: #{attributes.slice(:price, :quantity,
|
|
516
|
+
:order_type).inspect}")
|
|
504
517
|
response = self.class.resource.update(id, to_request_params)
|
|
505
518
|
if success_response?(response) && response["orderStatus"]
|
|
506
519
|
@attributes.merge!(normalize_keys(response))
|
|
507
520
|
assign_attributes
|
|
521
|
+
DhanHQ.logger&.info("[DhanHQ::Models::Order] Order modified successfully: #{id}")
|
|
508
522
|
true
|
|
509
523
|
else
|
|
524
|
+
error_msg = response.is_a?(Hash) ? response[:errorMessage] || response[:message] || "Unknown error" : "Invalid response format"
|
|
525
|
+
DhanHQ.logger&.error("[DhanHQ::Models::Order] Order modification failed for #{id}: #{error_msg}")
|
|
526
|
+
@errors = response if response.is_a?(Hash)
|
|
510
527
|
false
|
|
511
528
|
end
|
|
512
529
|
end
|