DhanHQ 3.1.0 → 3.2.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/ARCHITECTURE.md +47 -1
- data/CHANGELOG.md +73 -0
- data/README.md +241 -3
- data/docs/AUTHENTICATION.md +3 -5
- data/docs/CONFIGURATION.md +3 -2
- data/docs/CONSTANTS_REFERENCE.md +5 -4
- data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
- data/docs/LIVE_ORDER_UPDATES.md +5 -10
- data/docs/RAILS_INTEGRATION.md +1 -1
- data/docs/RELEASE_GUIDE.md +13 -2
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
- data/docs/WEBSOCKET_INTEGRATION.md +13 -20
- data/docs/WEBSOCKET_PROTOCOL.md +7 -3
- data/lib/DhanHQ/agent/key_coercion.rb +36 -0
- data/lib/DhanHQ/agent/tool.rb +37 -0
- data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
- data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
- data/lib/DhanHQ/agent/tool_registry.rb +17 -261
- data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
- data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
- data/lib/DhanHQ/client.rb +105 -30
- data/lib/DhanHQ/concerns/order_audit.rb +35 -4
- data/lib/DhanHQ/configuration.rb +70 -1
- data/lib/DhanHQ/constants.rb +101 -0
- data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
- data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
- data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
- data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
- data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
- data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
- data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
- data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
- data/lib/DhanHQ/dry_run/ledger.rb +71 -0
- data/lib/DhanHQ/dry_run/simulator.rb +140 -0
- data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
- data/lib/DhanHQ/models/alert_order.rb +6 -3
- data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
- data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
- data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
- data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
- data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
- data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
- data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
- data/lib/DhanHQ/models/margin.rb +5 -1
- data/lib/DhanHQ/models/multi_order.rb +130 -0
- data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
- data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
- data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
- data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
- data/lib/DhanHQ/resources/multi_orders.rb +58 -0
- data/lib/DhanHQ/risk/pipeline.rb +0 -2
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/write_paths.rb +57 -0
- data/lib/DhanHQ/ws/client.rb +117 -2
- data/lib/DhanHQ/ws/connection.rb +40 -11
- data/lib/DhanHQ/ws/sub_state.rb +14 -0
- data/lib/dhan_hq.rb +4 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
- data/skills/dhanhq-ruby/SKILL.md +2 -1
- data/skills/dhanhq-ruby/examples/order_management.rb +7 -0
- data/skills/dhanhq-ruby/references/common-workflows.md +8 -8
- data/skills/dhanhq-ruby/references/instruments.md +7 -1
- data/skills/dhanhq-ruby/references/orders.md +2 -0
- metadata +30 -10
- data/.rspec +0 -3
- data/.rubocop.yml +0 -50
- data/.rubocop_todo.yml +0 -217
- data/Rakefile +0 -14
- data/TAGS +0 -10
- data/core +0 -0
- data/diagram.html +0 -184
- data/watchlist.csv +0 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Resources
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
# Resource client for the Global Stocks pre-trade calculators.
|
|
7
|
+
#
|
|
8
|
+
# POST /v2/globalstocks/margincalculator — margin required for an order
|
|
9
|
+
# POST /v2/globalstocks/transEstimate — charges applicable to an order
|
|
10
|
+
#
|
|
11
|
+
# Both endpoints take the same request body. The API documents +price+ and
|
|
12
|
+
# +quantity+ as strings, so numeric input is stringified before sending.
|
|
13
|
+
class MarginCalculator < BaseAPI
|
|
14
|
+
API_TYPE = :non_trading_api
|
|
15
|
+
HTTP_PATH = "/v2/globalstocks"
|
|
16
|
+
|
|
17
|
+
# Calculates the margin required for a Global Stocks order.
|
|
18
|
+
#
|
|
19
|
+
# @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
|
|
20
|
+
# @return [Hash] Margin breakdown.
|
|
21
|
+
def calculate(params)
|
|
22
|
+
post("/margincalculator", params: wire_params(params))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Estimates the charges applicable to a Global Stocks order.
|
|
26
|
+
#
|
|
27
|
+
# @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
|
|
28
|
+
# @return [Hash] Charge breakdown.
|
|
29
|
+
def estimate(params)
|
|
30
|
+
post("/transEstimate", params: wire_params(params))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Validates against the shared estimator contract, then renders the numeric
|
|
36
|
+
# fields as strings the way the API expects them.
|
|
37
|
+
def wire_params(params)
|
|
38
|
+
validated = validate!(params)
|
|
39
|
+
validated.merge(
|
|
40
|
+
price: format_number(validated[:price]),
|
|
41
|
+
quantity: format_number(validated[:quantity])
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Fields the API documents as strings but which are validated as numbers.
|
|
46
|
+
NUMERIC_KEYS = %i[price quantity].freeze
|
|
47
|
+
private_constant :NUMERIC_KEYS
|
|
48
|
+
|
|
49
|
+
def validate!(params)
|
|
50
|
+
attrs = snake_case(params).each_with_object({}) do |(key, value), out|
|
|
51
|
+
out[key] = NUMERIC_KEYS.include?(key) ? Float(value) : value
|
|
52
|
+
end
|
|
53
|
+
result = Contracts::GlobalStocksEstimatorContract.new.call(attrs)
|
|
54
|
+
raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
|
|
55
|
+
|
|
56
|
+
result.to_h
|
|
57
|
+
rescue ArgumentError, TypeError => e
|
|
58
|
+
raise DhanHQ::ValidationError, "Invalid parameters: price and quantity must be numeric (#{e.message})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Renders 180.0 as "180" and 1.5 as "1.5" so the payload matches the
|
|
62
|
+
# documented examples rather than leaking Ruby float formatting.
|
|
63
|
+
def format_number(value)
|
|
64
|
+
float = Float(value)
|
|
65
|
+
float == float.to_i ? float.to_i.to_s : float.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Resources
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
# Resource client for the US market session status.
|
|
7
|
+
#
|
|
8
|
+
# GET /v2/globalstocks/marketstatus
|
|
9
|
+
class MarketStatus < BaseAPI
|
|
10
|
+
API_TYPE = :non_trading_api
|
|
11
|
+
HTTP_PATH = "/v2/globalstocks/marketstatus"
|
|
12
|
+
|
|
13
|
+
# Retrieves the current US market status and session timings.
|
|
14
|
+
#
|
|
15
|
+
# @return [Hash] `{ status:, marketOpenTime:, marketCloseTime:, holidayFlag: }`
|
|
16
|
+
def fetch
|
|
17
|
+
get("")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../concerns/order_audit"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Resources
|
|
7
|
+
module GlobalStocks
|
|
8
|
+
# Resource client for the Global Stocks (US equities) order endpoints.
|
|
9
|
+
#
|
|
10
|
+
# POST /v2/globalstocks/orders
|
|
11
|
+
# PUT /v2/globalstocks/orders/{order-id}
|
|
12
|
+
# DELETE /v2/globalstocks/orders/{order-id}
|
|
13
|
+
# GET /v2/globalstocks/orders
|
|
14
|
+
# GET /v2/globalstocks/orders/{order-id}
|
|
15
|
+
#
|
|
16
|
+
# Writes go through the same {DhanHQ::Concerns::OrderAudit} guardrails as
|
|
17
|
+
# domestic orders: +ENV["LIVE_TRADING"]="true"+ is required, and every attempt
|
|
18
|
+
# emits a structured audit log line.
|
|
19
|
+
#
|
|
20
|
+
# The pre-trade {DhanHQ::Risk::Pipeline} is intentionally *not* run here. Its
|
|
21
|
+
# checks (lot-size multiples, ASM/GSM surveillance lists, F&O product support,
|
|
22
|
+
# NSE/BSE market hours) resolve instruments from the Indian scrip master, which
|
|
23
|
+
# does not contain US securities.
|
|
24
|
+
#
|
|
25
|
+
# @see https://dhanhq.co/docs/v2/ Global Stocks section
|
|
26
|
+
class Orders < BaseAPI
|
|
27
|
+
include DhanHQ::Concerns::OrderAudit
|
|
28
|
+
|
|
29
|
+
API_TYPE = :order_api
|
|
30
|
+
HTTP_PATH = "/v2/globalstocks/orders"
|
|
31
|
+
|
|
32
|
+
# Places a new Global Stocks order.
|
|
33
|
+
#
|
|
34
|
+
# @param params [Hash] Order attributes in snake_case. See
|
|
35
|
+
# {DhanHQ::Contracts::GlobalStocksPlaceOrderContract} for the accepted keys.
|
|
36
|
+
# @return [Hash] `{ orderId:, orderStatus: }`
|
|
37
|
+
# @raise [DhanHQ::LiveTradingDisabledError] Unless LIVE_TRADING is enabled.
|
|
38
|
+
# @raise [DhanHQ::ValidationError] When the payload fails validation.
|
|
39
|
+
def create(params)
|
|
40
|
+
ensure_live_trading!
|
|
41
|
+
log_order_context("DHAN_GLOBAL_ORDER_ATTEMPT", params)
|
|
42
|
+
validate_place_order!(params)
|
|
43
|
+
post("", params: params)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Modifies a pending Global Stocks order.
|
|
47
|
+
#
|
|
48
|
+
# @param order_id [String]
|
|
49
|
+
# @param params [Hash] Fields to change, in snake_case.
|
|
50
|
+
# @return [Hash] `{ orderId:, orderStatus: }`
|
|
51
|
+
def update(order_id, params)
|
|
52
|
+
ensure_live_trading!
|
|
53
|
+
log_order_context("DHAN_GLOBAL_ORDER_MODIFY_ATTEMPT", params.merge(order_id: order_id))
|
|
54
|
+
validate_modify_order!(params.merge(order_id: order_id))
|
|
55
|
+
put("/#{order_id}", params: params)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Cancels a pending Global Stocks order.
|
|
59
|
+
#
|
|
60
|
+
# @param order_id [String]
|
|
61
|
+
# @return [Hash] `{ orderId:, orderStatus: }`
|
|
62
|
+
def cancel(order_id)
|
|
63
|
+
ensure_live_trading!
|
|
64
|
+
log_order_context("DHAN_GLOBAL_ORDER_CANCEL_ATTEMPT", { order_id: order_id })
|
|
65
|
+
delete("/#{order_id}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Retrieves the current trading day's Global Stocks order book.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<Hash>]
|
|
71
|
+
def all
|
|
72
|
+
get("")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Retrieves a single Global Stocks order by its Dhan order id.
|
|
76
|
+
#
|
|
77
|
+
# @param order_id [String]
|
|
78
|
+
# @return [Hash]
|
|
79
|
+
def find(order_id)
|
|
80
|
+
get("/#{order_id}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_place_order!(params)
|
|
86
|
+
result = Contracts::GlobalStocksPlaceOrderContract.new.call(coerce_for_validation(params))
|
|
87
|
+
raise_validation_error!(result) unless result.success?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_modify_order!(params)
|
|
91
|
+
result = Contracts::GlobalStocksModifyOrderContract.new.call(coerce_for_validation(params))
|
|
92
|
+
raise_validation_error!(result) unless result.success?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Global Stocks quantities and prices are floats (fractional shares are
|
|
96
|
+
# supported), so integers handed in by callers are widened before validation.
|
|
97
|
+
def coerce_for_validation(params)
|
|
98
|
+
snake_case(params).each_with_object({}) do |(key, value), out|
|
|
99
|
+
out[key] = FLOAT_KEYS.include?(key) && value.is_a?(Integer) ? value.to_f : value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
FLOAT_KEYS = %i[quantity price trigger_price stop_loss_price target_price amount].freeze
|
|
104
|
+
private_constant :FLOAT_KEYS
|
|
105
|
+
|
|
106
|
+
def raise_validation_error!(result)
|
|
107
|
+
raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Resources
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
# Resource client for the Global Stocks trade book.
|
|
7
|
+
#
|
|
8
|
+
# GET /v2/globalstocks/trades
|
|
9
|
+
# GET /v2/globalstocks/trades/{security-id}
|
|
10
|
+
class Trades < BaseAPI
|
|
11
|
+
API_TYPE = :order_api
|
|
12
|
+
HTTP_PATH = "/v2/globalstocks/trades"
|
|
13
|
+
|
|
14
|
+
# Retrieves the current trading day's executed Global Stocks trades.
|
|
15
|
+
#
|
|
16
|
+
# @return [Array<Hash>]
|
|
17
|
+
def all
|
|
18
|
+
get("")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Retrieves executed Global Stocks trades for a single security.
|
|
22
|
+
#
|
|
23
|
+
# @param security_id [String]
|
|
24
|
+
# @return [Array<Hash>]
|
|
25
|
+
def by_security_id(security_id)
|
|
26
|
+
get("/#{security_id}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../concerns/order_audit"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Resources
|
|
7
|
+
# Resource for basket orders: POST /v2/alerts/multi/orders.
|
|
8
|
+
#
|
|
9
|
+
# Places up to {DhanHQ::Contracts::MultiOrderContract::MAX_ORDERS} unconditional
|
|
10
|
+
# orders in a single request. Unlike {AlertOrders} there is no trigger condition —
|
|
11
|
+
# every leg is sent to the exchange immediately.
|
|
12
|
+
#
|
|
13
|
+
# @see https://dhanhq.co/docs/v2/ Conditional and Multi Order section
|
|
14
|
+
class MultiOrders < BaseAPI
|
|
15
|
+
include DhanHQ::Concerns::OrderAudit
|
|
16
|
+
|
|
17
|
+
API_TYPE = :order_api
|
|
18
|
+
HTTP_PATH = "/v2/alerts/multi/orders"
|
|
19
|
+
|
|
20
|
+
# Places a basket of orders.
|
|
21
|
+
#
|
|
22
|
+
# @param orders [Array<Hash>] Order legs in snake_case. Each leg requires
|
|
23
|
+
# +:sequence+, +:transaction_type+ and +:exchange_segment+.
|
|
24
|
+
# @param dhan_client_id [String, nil] Defaults to the configured client id.
|
|
25
|
+
# @return [Hash] `{ orders: [{ orderId:, sequence:, orderStatus: }, ...] }`
|
|
26
|
+
# @raise [DhanHQ::LiveTradingDisabledError] Unless LIVE_TRADING is enabled.
|
|
27
|
+
# @raise [DhanHQ::ValidationError] When any leg fails validation.
|
|
28
|
+
def create(orders, dhan_client_id: nil)
|
|
29
|
+
ensure_live_trading!
|
|
30
|
+
|
|
31
|
+
legs = Array(orders).map { |leg| snake_case(leg) }
|
|
32
|
+
payload = { dhan_client_id: dhan_client_id || DhanHQ.configuration&.client_id, orders: legs }.compact
|
|
33
|
+
|
|
34
|
+
validate!(payload)
|
|
35
|
+
legs.each { |leg| run_risk_checks!(leg) }
|
|
36
|
+
log_multi_order_context(legs)
|
|
37
|
+
|
|
38
|
+
# BaseAPI camelizes only the top level, which would leave the leg fields as
|
|
39
|
+
# `transaction_type`/`security_id` inside `orders` and get the basket rejected.
|
|
40
|
+
post("", params: payload.merge(orders: deep_camelize_keys(legs)))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate!(payload)
|
|
46
|
+
result = Contracts::MultiOrderContract.new.call(payload)
|
|
47
|
+
raise DhanHQ::ValidationError, "Invalid parameters: #{result.errors.to_h}" unless result.success?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Emits one audit line per leg so each order in the basket is traceable.
|
|
51
|
+
def log_multi_order_context(legs)
|
|
52
|
+
legs.each do |leg|
|
|
53
|
+
log_order_context("DHAN_MULTI_ORDER_ATTEMPT", leg)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/DhanHQ/risk/pipeline.rb
CHANGED
|
@@ -51,14 +51,12 @@ module DhanHQ
|
|
|
51
51
|
# @param type [Symbol] :equity or :options
|
|
52
52
|
# @return [true] if all checks pass
|
|
53
53
|
# @raise [DhanHQ::RiskViolation] on first failure
|
|
54
|
-
# rubocop:disable Naming/PredicateMethod
|
|
55
54
|
def self.run!(instrument:, args:, now: Time.now, type: :equity)
|
|
56
55
|
run_checks!(CHECKS, instrument, args, now)
|
|
57
56
|
run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
|
|
58
57
|
run_checks!(DAILY_CHECKS, instrument, args, now)
|
|
59
58
|
true
|
|
60
59
|
end
|
|
61
|
-
# rubocop:enable Naming/PredicateMethod
|
|
62
60
|
|
|
63
61
|
def self.run_checks!(checks, instrument, args, now)
|
|
64
62
|
checks.each do |check|
|
data/lib/DhanHQ/version.rb
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Classifies request paths by whether they change account state.
|
|
5
|
+
#
|
|
6
|
+
# The distinction that matters on this API is **mutating vs read-only**, not GET vs
|
|
7
|
+
# POST: the option chain, market feed, historical charts and the margin calculators
|
|
8
|
+
# are all read-only POSTs. Both dry-run simulation and non-idempotent retry gating
|
|
9
|
+
# need that answer, so it lives here rather than in {DhanHQ::Client}.
|
|
10
|
+
module WritePaths
|
|
11
|
+
# HTTP methods that can change server-side state.
|
|
12
|
+
WRITE_METHODS = %i[post put patch delete].freeze
|
|
13
|
+
|
|
14
|
+
# Path of the basket order endpoint, which answers with one result per leg
|
|
15
|
+
# rather than a single order id.
|
|
16
|
+
MULTI_ORDER_PATH = "/v2/alerts/multi/orders"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# True when this request changes real account state.
|
|
21
|
+
#
|
|
22
|
+
# @param method [Symbol] HTTP method
|
|
23
|
+
# @param path [String, nil] Request path
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def mutating?(method, path)
|
|
26
|
+
return false unless WRITE_METHODS.include?(method)
|
|
27
|
+
|
|
28
|
+
match?(path, DhanHQ::Constants::MUTATING_PATH_PREFIXES)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# True when this request places one or more orders.
|
|
32
|
+
#
|
|
33
|
+
# @param method [Symbol] HTTP method
|
|
34
|
+
# @param path [String, nil] Request path
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def order_placement?(method, path)
|
|
37
|
+
return false unless method == :post
|
|
38
|
+
|
|
39
|
+
match?(path, DhanHQ::Constants::ORDER_PLACEMENT_PATH_PREFIXES)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True for the basket order endpoint.
|
|
43
|
+
#
|
|
44
|
+
# @param path [String, nil] Request path
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def multi_order?(path)
|
|
47
|
+
path.to_s.start_with?(MULTI_ORDER_PATH)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def match?(path, prefixes)
|
|
51
|
+
return false if path.nil?
|
|
52
|
+
|
|
53
|
+
prefixes.any? { |prefix| path.start_with?(prefix) }
|
|
54
|
+
end
|
|
55
|
+
private_class_method :match?
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/DhanHQ/ws/client.rb
CHANGED
|
@@ -26,6 +26,9 @@ module DhanHQ
|
|
|
26
26
|
@state = SubState.new
|
|
27
27
|
@callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
|
|
28
28
|
@started = Concurrent::AtomicBoolean.new(false)
|
|
29
|
+
@last_message_at = Concurrent::AtomicReference.new(nil)
|
|
30
|
+
@connected_at = Concurrent::AtomicReference.new(nil)
|
|
31
|
+
@reconnects = Concurrent::AtomicFixnum.new(0)
|
|
29
32
|
|
|
30
33
|
token = DhanHQ.configuration.resolved_access_token
|
|
31
34
|
raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
|
|
@@ -43,7 +46,8 @@ module DhanHQ
|
|
|
43
46
|
return self if @started.true?
|
|
44
47
|
|
|
45
48
|
@started.make_true
|
|
46
|
-
@conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state
|
|
49
|
+
@conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state,
|
|
50
|
+
on_event: method(:handle_connection_event)) do |binary|
|
|
47
51
|
tick = Decoder.decode(binary)
|
|
48
52
|
emit(:tick, tick) if tick
|
|
49
53
|
end
|
|
@@ -90,6 +94,80 @@ module DhanHQ
|
|
|
90
94
|
@conn&.open? || false
|
|
91
95
|
end
|
|
92
96
|
|
|
97
|
+
# Timestamp of the most recently received frame.
|
|
98
|
+
#
|
|
99
|
+
# A feed can be "connected" and still be dead — the socket stays open while
|
|
100
|
+
# the server has stopped publishing. Long-running daemons should watch this
|
|
101
|
+
# rather than {#connected?} alone.
|
|
102
|
+
#
|
|
103
|
+
# @return [Time, nil] nil until the first frame arrives.
|
|
104
|
+
def last_message_at
|
|
105
|
+
@last_message_at.get
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Seconds since the last frame arrived.
|
|
109
|
+
#
|
|
110
|
+
# @return [Float, nil] nil until the first frame arrives.
|
|
111
|
+
def seconds_since_last_message
|
|
112
|
+
stamp = last_message_at
|
|
113
|
+
return nil unless stamp
|
|
114
|
+
|
|
115
|
+
Time.now - stamp
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether the feed looks alive: connected and delivering frames recently.
|
|
119
|
+
#
|
|
120
|
+
# @param stale_after [Numeric] Seconds of silence after which the feed is
|
|
121
|
+
# considered stale. The server pings roughly every 10 seconds, so the
|
|
122
|
+
# default allows several missed intervals.
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def healthy?(stale_after: 45)
|
|
125
|
+
return false unless connected?
|
|
126
|
+
|
|
127
|
+
idle = seconds_since_last_message
|
|
128
|
+
idle.nil? || idle <= stale_after
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Timestamp of the current connection's establishment.
|
|
132
|
+
#
|
|
133
|
+
# @return [Time, nil]
|
|
134
|
+
def connected_at
|
|
135
|
+
@connected_at.get
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Number of times the connection has been re-established since {#start}.
|
|
139
|
+
#
|
|
140
|
+
# @return [Integer]
|
|
141
|
+
def reconnect_count
|
|
142
|
+
@reconnects.value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Instruments currently subscribed, as `"SEGMENT:SECURITY_ID"` strings.
|
|
146
|
+
#
|
|
147
|
+
# Survives reconnects: the connection replays this set on every new session.
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<String>]
|
|
150
|
+
def subscriptions
|
|
151
|
+
@state.snapshot
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Point-in-time health snapshot, suitable for a monitoring endpoint or log line.
|
|
155
|
+
#
|
|
156
|
+
# @return [Hash]
|
|
157
|
+
def health
|
|
158
|
+
{
|
|
159
|
+
mode: @mode,
|
|
160
|
+
started: @started.true?,
|
|
161
|
+
connected: connected?,
|
|
162
|
+
healthy: healthy?,
|
|
163
|
+
connected_at: connected_at,
|
|
164
|
+
last_message_at: last_message_at,
|
|
165
|
+
seconds_since_last_message: seconds_since_last_message,
|
|
166
|
+
reconnect_count: reconnect_count,
|
|
167
|
+
subscription_count: subscriptions.size
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
93
171
|
# Subscribes to updates for a single instrument.
|
|
94
172
|
#
|
|
95
173
|
# @param segment [String, Symbol, Integer]
|
|
@@ -154,9 +232,27 @@ module DhanHQ
|
|
|
154
232
|
|
|
155
233
|
# Registers a callback for a given event.
|
|
156
234
|
#
|
|
157
|
-
#
|
|
235
|
+
# Supported events:
|
|
236
|
+
#
|
|
237
|
+
# - +:tick+ — a decoded market data packet.
|
|
238
|
+
# - +:open+ — the socket connected. Payload: `{ resubscribed: [...] }`.
|
|
239
|
+
# - +:reconnect+ — the socket re-connected after a drop. Payload:
|
|
240
|
+
# `{ attempt: Integer, resubscribed: [...] }`. Subscriptions are replayed
|
|
241
|
+
# automatically before this fires; use it to re-seed derived state such as
|
|
242
|
+
# candle builders or to alert.
|
|
243
|
+
# - +:close+ — the socket closed. Payload:
|
|
244
|
+
# `{ code:, reason:, reconnecting: Boolean }`.
|
|
245
|
+
# - +:error+ — a socket-level error. Payload: the error message.
|
|
246
|
+
#
|
|
247
|
+
# @param event [Symbol] Event name.
|
|
158
248
|
# @yieldparam payload [Object] Event payload.
|
|
159
249
|
# @return [DhanHQ::WS::Client] self.
|
|
250
|
+
#
|
|
251
|
+
# @example React to a reconnect
|
|
252
|
+
# client.on(:reconnect) do |info|
|
|
253
|
+
# logger.warn "feed reconnected (attempt #{info[:attempt]}), " \
|
|
254
|
+
# "#{info[:resubscribed].size} instruments restored"
|
|
255
|
+
# end
|
|
160
256
|
def on(event, &blk)
|
|
161
257
|
@callbacks[event] << blk
|
|
162
258
|
self
|
|
@@ -164,6 +260,25 @@ module DhanHQ
|
|
|
164
260
|
|
|
165
261
|
private
|
|
166
262
|
|
|
263
|
+
# Bridges connection-level lifecycle events onto the client's callback bus and
|
|
264
|
+
# updates the health counters. +:message+ is internal bookkeeping only — the
|
|
265
|
+
# decoded payload reaches users as +:tick+.
|
|
266
|
+
def handle_connection_event(event, payload)
|
|
267
|
+
case event
|
|
268
|
+
when :message
|
|
269
|
+
@last_message_at.set(Time.now)
|
|
270
|
+
return
|
|
271
|
+
when :open
|
|
272
|
+
@connected_at.set(Time.now)
|
|
273
|
+
when :reconnect
|
|
274
|
+
@reconnects.increment
|
|
275
|
+
when :close
|
|
276
|
+
@connected_at.set(nil)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
emit(event, payload)
|
|
280
|
+
end
|
|
281
|
+
|
|
167
282
|
def prune(hash) = { ExchangeSegment: hash[:ExchangeSegment], SecurityId: hash[:SecurityId] }
|
|
168
283
|
|
|
169
284
|
def emit(event, payload)
|
data/lib/DhanHQ/ws/connection.rb
CHANGED
|
@@ -23,13 +23,18 @@ module DhanHQ
|
|
|
23
23
|
# @param bus [DhanHQ::WS::CmdBus] Command queue feeding subscription
|
|
24
24
|
# changes.
|
|
25
25
|
# @param state [DhanHQ::WS::SubState] Tracks subscription status.
|
|
26
|
+
# @param on_event [Proc, nil] Optional callable invoked as
|
|
27
|
+
# +on_event.call(event, payload)+ for lifecycle events (+:open+, +:close+,
|
|
28
|
+
# +:error+, +:reconnect+). Lets the owning client surface connection state
|
|
29
|
+
# to user callbacks.
|
|
26
30
|
# @yield [binary]
|
|
27
31
|
# @yieldparam binary [String] Raw binary frame received from the socket.
|
|
28
|
-
def initialize(url:, mode:, bus:, state:, &on_binary)
|
|
32
|
+
def initialize(url:, mode:, bus:, state:, on_event: nil, &on_binary)
|
|
29
33
|
@url = url
|
|
30
34
|
@mode = mode
|
|
31
35
|
@bus = bus
|
|
32
36
|
@state = state
|
|
37
|
+
@on_event = on_event
|
|
33
38
|
@on_binary = on_binary
|
|
34
39
|
@stop = false
|
|
35
40
|
@stopping = false
|
|
@@ -90,11 +95,41 @@ module DhanHQ
|
|
|
90
95
|
|
|
91
96
|
private
|
|
92
97
|
|
|
98
|
+
# Notifies the owning client of a lifecycle event. Never lets a user callback
|
|
99
|
+
# take down the connection thread.
|
|
100
|
+
def notify(event, payload = nil)
|
|
101
|
+
@on_event&.call(event, payload)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
DhanHQ.logger&.error("[DhanHQ::WS] on_event(#{event}) raised #{e.class}: #{e.message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Restores subscriptions and starts the command drain for a freshly opened
|
|
107
|
+
# socket, then reports the transition.
|
|
108
|
+
#
|
|
109
|
+
# @param session [Integer] 1-based index of this connection attempt. Anything
|
|
110
|
+
# past the first is a reconnect.
|
|
111
|
+
def handle_open(session)
|
|
112
|
+
DhanHQ.logger&.info("[DhanHQ::WS] open")
|
|
113
|
+
|
|
114
|
+
# The server keeps no subscription state across connections, so the desired set
|
|
115
|
+
# is replayed on every new session. See SubState#resubscribe_payload.
|
|
116
|
+
snapshot = @state.resubscribe_payload
|
|
117
|
+
send_sub(snapshot) unless snapshot.empty?
|
|
118
|
+
@timer = EM.add_periodic_timer(0.25) { drain_and_send }
|
|
119
|
+
|
|
120
|
+
# :reconnect lets callers re-seed derived state (candle builders, caches)
|
|
121
|
+
# and see exactly which instruments were restored for them.
|
|
122
|
+
notify(:reconnect, { attempt: session - 1, resubscribed: snapshot }) if session > 1
|
|
123
|
+
notify(:open, { resubscribed: snapshot })
|
|
124
|
+
end
|
|
125
|
+
|
|
93
126
|
def loop_run
|
|
94
127
|
backoff = 2.0
|
|
128
|
+
sessions = 0
|
|
95
129
|
until @stop
|
|
96
130
|
failed = false
|
|
97
131
|
got_429 = false
|
|
132
|
+
sessions += 1
|
|
98
133
|
|
|
99
134
|
# respect any active cool-off window
|
|
100
135
|
sleep (@cooloff_until - Time.now).ceil if @cooloff_until && Time.now < @cooloff_until
|
|
@@ -103,18 +138,10 @@ module DhanHQ
|
|
|
103
138
|
EM.run do
|
|
104
139
|
@ws = Faye::WebSocket::Client.new(@url, nil, headers: default_headers)
|
|
105
140
|
|
|
106
|
-
@ws.on
|
|
107
|
-
DhanHQ.logger&.info("[DhanHQ::WS] open")
|
|
108
|
-
# re-subscribe snapshot on reconnect
|
|
109
|
-
snapshot = @state.snapshot.map do |k|
|
|
110
|
-
seg, sid = k.split(":")
|
|
111
|
-
{ ExchangeSegment: seg, SecurityId: sid }
|
|
112
|
-
end
|
|
113
|
-
send_sub(snapshot) unless snapshot.empty?
|
|
114
|
-
@timer = EM.add_periodic_timer(0.25) { drain_and_send }
|
|
115
|
-
end
|
|
141
|
+
@ws.on(:open) { |_| handle_open(sessions) }
|
|
116
142
|
|
|
117
143
|
@ws.on :message do |ev|
|
|
144
|
+
notify(:message, nil)
|
|
118
145
|
@on_binary&.call(ev.data) # raw frames to decoder
|
|
119
146
|
end
|
|
120
147
|
|
|
@@ -131,12 +158,14 @@ module DhanHQ
|
|
|
131
158
|
failed = (ev.code != 1000)
|
|
132
159
|
got_429 = ev.reason.to_s.include?("429")
|
|
133
160
|
end
|
|
161
|
+
notify(:close, { code: ev.code, reason: ev.reason, reconnecting: failed })
|
|
134
162
|
EM.stop
|
|
135
163
|
end
|
|
136
164
|
|
|
137
165
|
@ws.on :error do |ev|
|
|
138
166
|
DhanHQ.logger&.error("[DhanHQ::WS] error #{ev.message}")
|
|
139
167
|
failed = true
|
|
168
|
+
notify(:error, ev.message)
|
|
140
169
|
end
|
|
141
170
|
end
|
|
142
171
|
rescue StandardError => e
|
data/lib/DhanHQ/ws/sub_state.rb
CHANGED
|
@@ -51,6 +51,20 @@ module DhanHQ
|
|
|
51
51
|
@mutex.synchronize { @set.to_a }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The desired subscriptions rendered as instrument hashes, ready to be sent as a
|
|
55
|
+
# subscribe frame.
|
|
56
|
+
#
|
|
57
|
+
# The server keeps no subscription state across connections, so a reconnecting
|
|
58
|
+
# client replays this on every new session.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Hash>] Instruments with +:ExchangeSegment+ and +:SecurityId+.
|
|
61
|
+
def resubscribe_payload
|
|
62
|
+
snapshot.map do |key|
|
|
63
|
+
segment, security_id = key.split(":")
|
|
64
|
+
{ ExchangeSegment: segment, SecurityId: security_id }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
private
|
|
55
69
|
|
|
56
70
|
def key_for(instrument) = "#{instrument[:ExchangeSegment]}:#{instrument[:SecurityId]}"
|
data/lib/dhan_hq.rb
CHANGED
|
@@ -26,6 +26,10 @@ module DhanHQ
|
|
|
26
26
|
LOADER = Zeitwerk::Loader.new
|
|
27
27
|
LOADER.tag = "dhanhq"
|
|
28
28
|
LOADER.inflector.inflect(
|
|
29
|
+
# Without this, Zeitwerk looks for DhanHQ::Ai while ai.rb defines DhanHQ::AI, so
|
|
30
|
+
# DhanHQ::AI::PromptHelpers raised NameError unless something had already loaded
|
|
31
|
+
# the file by hand — which only mcp/server.rb did.
|
|
32
|
+
"ai" => "AI",
|
|
29
33
|
"api_helper" => "APIHelper",
|
|
30
34
|
"auth_api" => "AuthAPI",
|
|
31
35
|
"base_api" => "BaseAPI",
|