DhanHQ 4.1.0 → 4.1.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/CHANGELOG.md +47 -0
- data/GUIDE.md +3 -0
- data/README.md +17 -3
- data/docs/AUTHENTICATION.md +13 -2
- data/docs/CONFIGURATION.md +7 -0
- data/docs/ENDPOINTS_AND_SANDBOX.md +35 -32
- data/lib/DhanHQ/auth.rb +58 -29
- data/lib/DhanHQ/client.rb +25 -0
- data/lib/DhanHQ/concerns/order_audit.rb +13 -1
- data/lib/DhanHQ/configuration.rb +79 -0
- data/lib/DhanHQ/constants.rb +27 -1
- data/lib/DhanHQ/errors.rb +6 -0
- data/lib/DhanHQ/helpers/request_helper.rb +35 -6
- data/lib/DhanHQ/helpers/response_helper.rb +1 -1
- data/lib/DhanHQ/models/iceberg_order.rb +1 -1
- data/lib/DhanHQ/models/super_order.rb +1 -1
- data/lib/DhanHQ/models/twap_order.rb +1 -1
- data/lib/DhanHQ/resources/instruments.rb +30 -0
- data/lib/DhanHQ/resources/margin_calculator.rb +4 -1
- data/lib/DhanHQ/risk/checks/asm_gsm.rb +4 -1
- data/lib/DhanHQ/risk/checks/concentration.rb +80 -11
- data/lib/DhanHQ/risk/checks/max_loss.rb +11 -6
- data/lib/DhanHQ/risk/checks/options.rb +4 -1
- data/lib/DhanHQ/risk/checks/order_type.rb +3 -1
- data/lib/DhanHQ/risk/checks/position_limits.rb +7 -4
- data/lib/DhanHQ/risk/checks/quantity.rb +6 -3
- data/lib/DhanHQ/risk/exposure.rb +48 -0
- data/lib/DhanHQ/risk/limits.rb +75 -0
- data/lib/DhanHQ/risk/pipeline.rb +17 -9
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/ws/market_depth/client.rb +83 -24
- data/lib/DhanHQ/ws/market_depth/decoder.rb +109 -102
- data/lib/dhan_hq.rb +2 -0
- metadata +4 -2
|
@@ -40,7 +40,7 @@ module DhanHQ
|
|
|
40
40
|
# Public CSV endpoint for segment-wise instruments requires no auth
|
|
41
41
|
return { "Accept" => "text/csv" } if path.start_with?("/v2/instrument/")
|
|
42
42
|
|
|
43
|
-
token =
|
|
43
|
+
token = request_access_token
|
|
44
44
|
raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
|
|
45
45
|
|
|
46
46
|
headers = {
|
|
@@ -51,7 +51,7 @@ module DhanHQ
|
|
|
51
51
|
|
|
52
52
|
# Add client-id for DATA APIs (now including sandbox profile/funds)
|
|
53
53
|
if data_api?(path)
|
|
54
|
-
client_id =
|
|
54
|
+
client_id = request_client_id
|
|
55
55
|
unless client_id
|
|
56
56
|
raise DhanHQ::InvalidAuthenticationError,
|
|
57
57
|
"client_id is required for DATA APIs but not set. Please configure DhanHQ with DHAN_CLIENT_ID."
|
|
@@ -66,6 +66,18 @@ module DhanHQ
|
|
|
66
66
|
current_config&.resolved_access_token
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Access token for the request being built. Overridden by {DhanHQ::Client}
|
|
70
|
+
# to pick the sandbox or production token for its API tier.
|
|
71
|
+
def request_access_token
|
|
72
|
+
resolved_access_token
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Client ID for the request being built. Overridden by {DhanHQ::Client}
|
|
76
|
+
# to pick the sandbox or production client ID for its API tier.
|
|
77
|
+
def request_client_id
|
|
78
|
+
current_config&.client_id
|
|
79
|
+
end
|
|
80
|
+
|
|
69
81
|
# Determines if the API path requires a `client-id` header.
|
|
70
82
|
#
|
|
71
83
|
# @param path [String] The API endpoint path.
|
|
@@ -88,12 +100,12 @@ module DhanHQ
|
|
|
88
100
|
"Invalid payload: Expected a Hash, got #{payload.class}"
|
|
89
101
|
end
|
|
90
102
|
|
|
91
|
-
out = payload
|
|
103
|
+
out = rebind_dhan_client_id(payload)
|
|
92
104
|
if path && %i[post put patch].include?(method)
|
|
93
|
-
client_id =
|
|
105
|
+
client_id = request_client_id
|
|
94
106
|
needs_client_id = data_api?(path) || payload_requires_dhan_client_id?(path)
|
|
95
|
-
if client_id && needs_client_id && !
|
|
96
|
-
out =
|
|
107
|
+
if client_id && needs_client_id && !out.key?(:dhanClientId) && !out.key?("dhanClientId")
|
|
108
|
+
out = out.dup
|
|
97
109
|
if out.keys.any?(String)
|
|
98
110
|
out["dhanClientId"] = client_id
|
|
99
111
|
else
|
|
@@ -109,6 +121,23 @@ module DhanHQ
|
|
|
109
121
|
end
|
|
110
122
|
end
|
|
111
123
|
|
|
124
|
+
# Models fill +dhanClientId+ from the production {Configuration#client_id}.
|
|
125
|
+
# When this request authenticates with a different client ID (the sandbox
|
|
126
|
+
# one, in +:hybrid+ mode) the body must name the same account as the
|
|
127
|
+
# token, so a production ID is swapped for the request's ID. Only a value
|
|
128
|
+
# equal to the production ID is rewritten; an explicitly different ID is
|
|
129
|
+
# left alone.
|
|
130
|
+
def rebind_dhan_client_id(payload)
|
|
131
|
+
production_id = current_config&.client_id
|
|
132
|
+
request_id = request_client_id
|
|
133
|
+
return payload if production_id.nil? || request_id.nil? || production_id.to_s == request_id.to_s
|
|
134
|
+
|
|
135
|
+
key = [:dhanClientId, "dhanClientId"].find { |k| payload.key?(k) }
|
|
136
|
+
return payload unless key && payload[key].to_s == production_id.to_s
|
|
137
|
+
|
|
138
|
+
payload.merge(key => request_id)
|
|
139
|
+
end
|
|
140
|
+
|
|
112
141
|
# True when the path is one where the request body must include dhanClientId (order-api style).
|
|
113
142
|
def payload_requires_dhan_client_id?(path)
|
|
114
143
|
return false if path.nil? || path.empty?
|
|
@@ -54,7 +54,7 @@ module DhanHQ
|
|
|
54
54
|
def handle_error(response)
|
|
55
55
|
body = parse_json(response.body)
|
|
56
56
|
|
|
57
|
-
error_code = body[:errorCode] || response.status.to_s
|
|
57
|
+
error_code = (body[:errorCode] || response.status).to_s
|
|
58
58
|
error_message = body[:errorMessage] || body[:message] || "Unknown error"
|
|
59
59
|
if error_code == DhanHQ::Constants::TradingErrorCode::NO_HOLDINGS
|
|
60
60
|
error_message = "No holdings found for this account. Add holdings or wait for them to settle before retrying."
|
|
@@ -108,7 +108,7 @@ module DhanHQ
|
|
|
108
108
|
response = resource.create(formatted)
|
|
109
109
|
return nil unless response.is_a?(Hash) && response["orderId"]
|
|
110
110
|
|
|
111
|
-
new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
|
|
111
|
+
new({ order_id: response["orderId"], order_status: response["orderStatus"] }, skip_validation: true)
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
@@ -222,7 +222,7 @@ module DhanHQ
|
|
|
222
222
|
response = resource.create(normalized_params)
|
|
223
223
|
return nil unless response.is_a?(Hash) && response["orderId"]
|
|
224
224
|
|
|
225
|
-
new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
|
|
225
|
+
new({ order_id: response["orderId"], order_status: response["orderStatus"] }, skip_validation: true)
|
|
226
226
|
end
|
|
227
227
|
end
|
|
228
228
|
|
|
@@ -105,7 +105,7 @@ module DhanHQ
|
|
|
105
105
|
response = resource.create(formatted)
|
|
106
106
|
return nil unless response.is_a?(Hash) && response["orderId"]
|
|
107
107
|
|
|
108
|
-
new(order_id: response["orderId"], order_status: response["orderStatus"], skip_validation: true)
|
|
108
|
+
new({ order_id: response["orderId"], order_status: response["orderStatus"] }, skip_validation: true)
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -14,6 +14,7 @@ module DhanHQ
|
|
|
14
14
|
#
|
|
15
15
|
# @param exchange_segment [String] e.g. "NSE_EQ", "NSE_FNO", "IDX_I"
|
|
16
16
|
# @return [String] CSV content
|
|
17
|
+
# @raise [DhanHQ::Error] When the API answers with an HTTP error.
|
|
17
18
|
def by_segment(exchange_segment)
|
|
18
19
|
path = "#{HTTP_PATH}/#{exchange_segment}"
|
|
19
20
|
resp = client.connection.get(path)
|
|
@@ -21,9 +22,38 @@ module DhanHQ
|
|
|
21
22
|
redirect_url = resp.headers["location"]
|
|
22
23
|
Faraday.get(redirect_url).body
|
|
23
24
|
else
|
|
25
|
+
raise_for_failure!(resp) if resp.status >= 400
|
|
26
|
+
|
|
24
27
|
resp.body
|
|
25
28
|
end
|
|
26
29
|
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Raises the error class mapped to the response status, so this non-JSON
|
|
34
|
+
# endpoint fails like every other resource instead of handing the caller a
|
|
35
|
+
# raw error body dressed up as CSV.
|
|
36
|
+
#
|
|
37
|
+
# @param resp [Faraday::Response]
|
|
38
|
+
# @raise [DhanHQ::Error]
|
|
39
|
+
def raise_for_failure!(resp)
|
|
40
|
+
body = parse_error_body(resp.body)
|
|
41
|
+
message = "#{body[:errorCode] || resp.status}: #{body[:errorMessage] || "Unknown error"}"
|
|
42
|
+
klass = DhanHQ::ResponseHelper::STATUS_ERROR_FALLBACK.fetch(resp.status) do
|
|
43
|
+
resp.status.between?(500, 599) ? DhanHQ::InternalServerError : DhanHQ::OtherError
|
|
44
|
+
end
|
|
45
|
+
raise klass, message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Best-effort parse of a JSON error body; non-JSON bodies degrade to the raw text.
|
|
49
|
+
#
|
|
50
|
+
# @param raw [String]
|
|
51
|
+
# @return [Hash]
|
|
52
|
+
def parse_error_body(raw)
|
|
53
|
+
JSON.parse(raw.to_s, symbolize_names: true)
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
{}
|
|
56
|
+
end
|
|
27
57
|
end
|
|
28
58
|
end
|
|
29
59
|
end
|
|
@@ -24,7 +24,10 @@ module DhanHQ
|
|
|
24
24
|
# @param params [Hash] Request parameters including scripList, includePosition, includeOrder.
|
|
25
25
|
# @return [Hash] API response containing combined margin details with hedge benefit.
|
|
26
26
|
def calculate_multi(params)
|
|
27
|
-
|
|
27
|
+
# Deep-camelize: the scripList entries are hashes, and the API expects
|
|
28
|
+
# camelCase keys inside them (securityId, exchangeSegment, ...), which the
|
|
29
|
+
# shallow top-level camelize would leave in snake_case.
|
|
30
|
+
post("/multi", params: deep_camelize_keys(params))
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
end
|
|
@@ -5,7 +5,10 @@ module DhanHQ
|
|
|
5
5
|
module Checks
|
|
6
6
|
# Blocks trading on ASM/GSM restricted instruments.
|
|
7
7
|
class AsmGsm
|
|
8
|
-
|
|
8
|
+
# Exits are allowed: a position held before the instrument entered ASM/GSM
|
|
9
|
+
# must still be closable.
|
|
10
|
+
def self.run!(instrument:, reducing: false, **_unused)
|
|
11
|
+
return if reducing
|
|
9
12
|
return unless instrument.asm_gsm_flag == "Y"
|
|
10
13
|
|
|
11
14
|
raise DhanHQ::RiskViolation,
|
|
@@ -7,30 +7,99 @@ module DhanHQ
|
|
|
7
7
|
class Concentration
|
|
8
8
|
MAX_CONCENTRATION_PCT = 25.0
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Exposure in one instrument — open positions plus this order's notional —
|
|
11
|
+
# as a share of available funds. Positions match on security id (and segment
|
|
12
|
+
# when given) or, when the order names one, on trading symbol. Order params
|
|
13
|
+
# usually carry only a security id, and comparing that against a position's
|
|
14
|
+
# trading symbol never matched, which left this check inert for orders.
|
|
15
|
+
#
|
|
16
|
+
# A MARKET entry carries no price, so its notional is estimated from the
|
|
17
|
+
# trigger price or the instrument's LTP; an entry that cannot be priced is
|
|
18
|
+
# refused rather than counted as zero exposure.
|
|
19
|
+
def self.run!(args:, positions: nil, reducing: false, instrument: nil, **_unused)
|
|
20
|
+
security_id = args["security_id"].to_s
|
|
21
|
+
symbol = args["trading_symbol"].to_s
|
|
22
|
+
return if reducing || (security_id.empty? && symbol.empty?)
|
|
13
23
|
|
|
14
24
|
funds = DhanHQ::Models::Funds.fetch
|
|
15
25
|
available = funds.available_balance.to_f
|
|
16
26
|
return if available <= 0
|
|
17
27
|
|
|
18
|
-
positions
|
|
19
|
-
symbol_positions = positions.select
|
|
20
|
-
sym = p.trading_symbol || p.security_id
|
|
21
|
-
sym.to_s == symbol.to_s
|
|
22
|
-
end
|
|
28
|
+
positions ||= DhanHQ::Models::Position.all
|
|
29
|
+
symbol_positions = positions.select { |p| same_instrument?(p, args, security_id, symbol) }
|
|
23
30
|
|
|
24
31
|
current_exposure = symbol_positions.sum do |p|
|
|
25
32
|
p.net_qty.to_i.abs * p.cost_price.to_f
|
|
26
33
|
end
|
|
34
|
+
order_notional = order_notional(args, instrument)
|
|
35
|
+
limit = Limits.value(:max_concentration_pct, MAX_CONCENTRATION_PCT)
|
|
27
36
|
|
|
28
|
-
concentration_pct = (current_exposure / available) * 100.0
|
|
29
|
-
return if concentration_pct <=
|
|
37
|
+
concentration_pct = ((current_exposure + order_notional) / available) * 100.0
|
|
38
|
+
return if concentration_pct <= limit
|
|
30
39
|
|
|
31
40
|
raise DhanHQ::RiskViolation,
|
|
32
|
-
"Concentration #{concentration_pct.round(1)}% exceeds #{
|
|
41
|
+
"Concentration #{concentration_pct.round(1)}% exceeds #{limit}% limit for #{symbol.empty? ? security_id : symbol}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.order_notional(args, instrument)
|
|
45
|
+
quantity = args["quantity"].to_i
|
|
46
|
+
return 0.0 unless quantity.positive?
|
|
47
|
+
|
|
48
|
+
quantity * order_price!(args, instrument)
|
|
33
49
|
end
|
|
50
|
+
|
|
51
|
+
def self.order_price!(args, instrument)
|
|
52
|
+
price = [args["price"], args["trigger_price"]].map(&:to_f).find(&:positive?)
|
|
53
|
+
return price if price
|
|
54
|
+
return unpriced_paper_order(args) if paper_order_without_quotes?
|
|
55
|
+
|
|
56
|
+
price = live_price(instrument)
|
|
57
|
+
return price if price&.positive?
|
|
58
|
+
|
|
59
|
+
raise DhanHQ::RiskViolation,
|
|
60
|
+
"Cannot price #{args["security_id"] || args["trading_symbol"]} for the concentration check " \
|
|
61
|
+
"(no price, trigger price or LTP)"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Full sandbox: the order is a paper order and the sandbox serves no quotes
|
|
65
|
+
# (GET /marketfeed/ltp is refused there), so a MARKET entry cannot be
|
|
66
|
+
# priced. Refusing it would block every paper market order for no money at
|
|
67
|
+
# risk; count only existing exposure instead. A real order is never let
|
|
68
|
+
# through this way: it needs the order tier on the sandbox too.
|
|
69
|
+
def self.paper_order_without_quotes?
|
|
70
|
+
config = DhanHQ.configuration
|
|
71
|
+
return false unless config
|
|
72
|
+
|
|
73
|
+
config.environment_for(:order_api) == :sandbox && config.environment_for(:data_api) == :sandbox
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.unpriced_paper_order(args)
|
|
77
|
+
DhanHQ.logger&.info(
|
|
78
|
+
"[DhanHQ::Risk::Concentration] Sandbox has no quotes; #{args["security_id"]} order notional not counted"
|
|
79
|
+
)
|
|
80
|
+
0.0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A failed LTP fetch must refuse the entry, not escape as a generic error:
|
|
84
|
+
# OrderAudit treats unexpected errors as "skip every check".
|
|
85
|
+
def self.live_price(instrument)
|
|
86
|
+
return nil unless instrument.respond_to?(:ltp)
|
|
87
|
+
|
|
88
|
+
instrument.ltp.to_f
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
DhanHQ.logger&.warn("[DhanHQ::Risk::Concentration] LTP fetch failed: #{e.class} #{e.message}")
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.same_instrument?(position, args, security_id, symbol)
|
|
95
|
+
return position.trading_symbol.to_s == symbol unless symbol.empty?
|
|
96
|
+
|
|
97
|
+
position.security_id.to_s == security_id &&
|
|
98
|
+
(args["exchange_segment"].nil? || position.exchange_segment.to_s == args["exchange_segment"].to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private_class_method :order_notional, :order_price!, :paper_order_without_quotes?, :unpriced_paper_order,
|
|
102
|
+
:live_price, :same_instrument?
|
|
34
103
|
end
|
|
35
104
|
end
|
|
36
105
|
end
|
|
@@ -7,16 +7,21 @@ module DhanHQ
|
|
|
7
7
|
class MaxLoss
|
|
8
8
|
DAILY_MAX_LOSS = 50_000
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# Day P&L is realized plus unrealized: a loss booked earlier in the day
|
|
11
|
+
# counts against the limit just as an open one does.
|
|
12
|
+
def self.run!(positions: nil, reducing: false, **_unused)
|
|
13
|
+
return if reducing
|
|
14
|
+
|
|
15
|
+
positions ||= DhanHQ::Models::Position.all
|
|
16
|
+
day_pnl = positions.sum do |p|
|
|
17
|
+
p.realized_profit.to_f + p.unrealized_profit.to_f
|
|
14
18
|
end
|
|
19
|
+
limit = Limits.value(:daily_max_loss, DAILY_MAX_LOSS)
|
|
15
20
|
|
|
16
|
-
return if
|
|
21
|
+
return if day_pnl >= -limit
|
|
17
22
|
|
|
18
23
|
raise DhanHQ::RiskViolation,
|
|
19
|
-
"Daily loss limit of ₹#{
|
|
24
|
+
"Daily loss limit of ₹#{limit} exceeded (current: ₹#{day_pnl.round(0)})"
|
|
20
25
|
end
|
|
21
26
|
end
|
|
22
27
|
end
|
|
@@ -5,7 +5,10 @@ module DhanHQ
|
|
|
5
5
|
module Checks
|
|
6
6
|
# Enforces option-specific risk rules (index-only, stop loss, target, risk-reward).
|
|
7
7
|
class Options
|
|
8
|
-
|
|
8
|
+
# Entry rules only: closing an option position needs no stop loss or target.
|
|
9
|
+
def self.run!(args:, instrument:, reducing: false, **_unused)
|
|
10
|
+
return if reducing
|
|
11
|
+
|
|
9
12
|
enforce_index!(instrument)
|
|
10
13
|
enforce_stop_loss!(args)
|
|
11
14
|
enforce_target!(args)
|
|
@@ -5,7 +5,9 @@ module DhanHQ
|
|
|
5
5
|
module Checks
|
|
6
6
|
# Restricts order types to MARKET and LIMIT only.
|
|
7
7
|
class OrderType
|
|
8
|
-
|
|
8
|
+
# Every order type the Dhan order APIs accept. STOP_LOSS / STOP_LOSS_MARKET
|
|
9
|
+
# must pass: they are how a stop-loss is placed.
|
|
10
|
+
VALID_TYPES = DhanHQ::Constants::OrderType::ALL
|
|
9
11
|
|
|
10
12
|
def self.run!(args:, **_unused)
|
|
11
13
|
order_type = args["order_type"]
|
|
@@ -7,16 +7,19 @@ module DhanHQ
|
|
|
7
7
|
class PositionLimits
|
|
8
8
|
MAX_OPEN_POSITIONS = 20
|
|
9
9
|
|
|
10
|
-
def self.run!(**_unused)
|
|
11
|
-
|
|
10
|
+
def self.run!(positions: nil, reducing: false, **_unused)
|
|
11
|
+
return if reducing
|
|
12
|
+
|
|
13
|
+
positions ||= DhanHQ::Models::Position.all
|
|
12
14
|
open_count = positions.count do |p|
|
|
13
15
|
p.net_qty.to_i != 0
|
|
14
16
|
end
|
|
17
|
+
limit = Limits.value(:max_open_positions, MAX_OPEN_POSITIONS)
|
|
15
18
|
|
|
16
|
-
return if open_count <
|
|
19
|
+
return if open_count < limit
|
|
17
20
|
|
|
18
21
|
raise DhanHQ::RiskViolation,
|
|
19
|
-
"Maximum #{
|
|
22
|
+
"Maximum #{limit} open positions exceeded (#{open_count} open)"
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
end
|
|
@@ -8,10 +8,13 @@ module DhanHQ
|
|
|
8
8
|
MAX_QUANTITY = 10
|
|
9
9
|
MAX_NOTIONAL = 100_000
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# Size caps apply to new exposure only; an exit of any size that does not
|
|
12
|
+
# exceed the open position is allowed.
|
|
13
|
+
def self.run!(args:, reducing: false, **_unused)
|
|
12
14
|
quantity = args["quantity"].to_i
|
|
13
15
|
raise DhanHQ::RiskViolation, "Quantity must be > 0" unless quantity.positive?
|
|
14
|
-
|
|
16
|
+
return if reducing
|
|
17
|
+
raise DhanHQ::RiskViolation, "Quantity exceeds limit" if quantity > Limits.value(:max_quantity, MAX_QUANTITY)
|
|
15
18
|
|
|
16
19
|
enforce_notional_limit!(quantity, args["price"])
|
|
17
20
|
end
|
|
@@ -20,7 +23,7 @@ module DhanHQ
|
|
|
20
23
|
return unless price
|
|
21
24
|
|
|
22
25
|
notional = quantity * price.to_f
|
|
23
|
-
return if notional <= MAX_NOTIONAL
|
|
26
|
+
return if notional <= Limits.value(:max_notional, MAX_NOTIONAL)
|
|
24
27
|
|
|
25
28
|
raise DhanHQ::RiskViolation, "Notional exceeds limit"
|
|
26
29
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
# Classifies an order against the account's open positions.
|
|
6
|
+
#
|
|
7
|
+
# A risk-reducing order — the opposite side of an open position in the same
|
|
8
|
+
# instrument and product, for no more than that position's size — only shrinks
|
|
9
|
+
# exposure. Limit checks (position count, daily loss, concentration, size caps,
|
|
10
|
+
# ASM/GSM) must never block it: refusing a stop-out because the account is
|
|
11
|
+
# already over a limit turns a capped loss into an uncapped one.
|
|
12
|
+
module Exposure
|
|
13
|
+
# @param args [Hash] Order arguments with snake_case String keys.
|
|
14
|
+
# @param positions [Array<#security_id, #exchange_segment, #product_type, #net_qty>]
|
|
15
|
+
# @return [Boolean] True when the order only reduces an existing position.
|
|
16
|
+
def self.reducing?(args, positions)
|
|
17
|
+
quantity = args["quantity"].to_i
|
|
18
|
+
side = args["transaction_type"].to_s.upcase
|
|
19
|
+
return false unless quantity.positive? && Constants::TransactionType::ALL.include?(side)
|
|
20
|
+
|
|
21
|
+
position = matching_position(args, positions)
|
|
22
|
+
return false unless position
|
|
23
|
+
|
|
24
|
+
net = position.net_qty.to_i
|
|
25
|
+
closes = side == Constants::TransactionType::SELL ? net.positive? : net.negative?
|
|
26
|
+
closes && quantity <= net.abs
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.matching_position(args, positions)
|
|
30
|
+
security_id = args["security_id"].to_s
|
|
31
|
+
return nil if security_id.empty?
|
|
32
|
+
|
|
33
|
+
Array(positions).find do |position|
|
|
34
|
+
position.net_qty.to_i != 0 &&
|
|
35
|
+
position.security_id.to_s == security_id &&
|
|
36
|
+
same_or_unspecified?(args["exchange_segment"], position.exchange_segment) &&
|
|
37
|
+
same_or_unspecified?(args["product_type"], position.product_type)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.same_or_unspecified?(wanted, actual)
|
|
42
|
+
wanted.nil? || wanted.to_s.empty? || wanted.to_s == actual.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :matching_position, :same_or_unspecified?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Risk
|
|
5
|
+
# Configurable thresholds for the pre-trade risk checks.
|
|
6
|
+
#
|
|
7
|
+
# Each check keeps its historical constant as the default, so an unconfigured
|
|
8
|
+
# client behaves exactly as before. Override per process with
|
|
9
|
+
# {DhanHQ::Configuration#risk_limits} or the matching +DHAN_RISK_*+ variable:
|
|
10
|
+
#
|
|
11
|
+
# DhanHQ.configure do |c|
|
|
12
|
+
# c.risk_limits = { max_quantity: 1_800, max_notional: 250_000 }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# +max_quantity+ is in units as sent to the API (one NIFTY lot is 75 units),
|
|
16
|
+
# not in lots.
|
|
17
|
+
module Limits
|
|
18
|
+
# Limit name => environment variable that sets it.
|
|
19
|
+
ENV_KEYS = {
|
|
20
|
+
max_quantity: "DHAN_RISK_MAX_QUANTITY",
|
|
21
|
+
max_notional: "DHAN_RISK_MAX_NOTIONAL",
|
|
22
|
+
max_open_positions: "DHAN_RISK_MAX_OPEN_POSITIONS",
|
|
23
|
+
daily_max_loss: "DHAN_RISK_DAILY_MAX_LOSS",
|
|
24
|
+
max_concentration_pct: "DHAN_RISK_MAX_CONCENTRATION_PCT"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Every limit name {DhanHQ::Configuration#risk_limits} accepts.
|
|
28
|
+
NAMES = ENV_KEYS.keys.freeze
|
|
29
|
+
|
|
30
|
+
# @param name [Symbol] One of {NAMES}.
|
|
31
|
+
# @param default [Numeric] The check's built-in threshold.
|
|
32
|
+
# @return [Numeric] The configured threshold, or +default+.
|
|
33
|
+
def self.value(name, default)
|
|
34
|
+
DhanHQ.configuration&.risk_limits&.fetch(name, nil) || default
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Validates and normalises a limits hash (String or Symbol keys, numeric values).
|
|
38
|
+
#
|
|
39
|
+
# @param raw [Hash, nil]
|
|
40
|
+
# @return [Hash{Symbol => Numeric}]
|
|
41
|
+
# @raise [ArgumentError] On an unknown name or a non-positive / non-numeric value.
|
|
42
|
+
def self.coerce(raw)
|
|
43
|
+
return {} if raw.nil?
|
|
44
|
+
raise ArgumentError, "risk_limits must be a Hash of #{NAMES.join(", ")}" unless raw.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
raw.each_with_object({}) do |(name, value), out|
|
|
47
|
+
key = name.to_s.strip.to_sym
|
|
48
|
+
raise ArgumentError, "unknown risk limit #{name.inspect}: expected one of #{NAMES.join(", ")}" unless NAMES.include?(key)
|
|
49
|
+
|
|
50
|
+
out[key] = positive_number!(key, value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Limits set through +DHAN_RISK_*+ environment variables.
|
|
55
|
+
#
|
|
56
|
+
# @return [Hash{Symbol => Numeric}]
|
|
57
|
+
def self.from_env
|
|
58
|
+
coerce(ENV_KEYS.each_with_object({}) do |(name, env), out|
|
|
59
|
+
raw = ENV.fetch(env, "").strip
|
|
60
|
+
out[name] = raw unless raw.empty?
|
|
61
|
+
end)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.positive_number!(key, value)
|
|
65
|
+
number = Float(value.to_s, exception: false)
|
|
66
|
+
raise ArgumentError, "risk limit #{key} must be a number (got #{value.inspect})" if number.nil?
|
|
67
|
+
raise ArgumentError, "risk limit #{key} must be positive (got #{value.inspect})" unless number.positive?
|
|
68
|
+
|
|
69
|
+
(number % 1).zero? ? number.to_i : number
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method :positive_number!
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/DhanHQ/risk/pipeline.rb
CHANGED
|
@@ -7,6 +7,11 @@ module DhanHQ
|
|
|
7
7
|
# Runs a sequence of checks against the instrument and order arguments.
|
|
8
8
|
# Raises DhanHQ::RiskViolation on the first failure.
|
|
9
9
|
#
|
|
10
|
+
# Open positions are fetched once per run and shared by every check. Orders that
|
|
11
|
+
# only reduce an open position (see {Exposure.reducing?}) skip the limit checks
|
|
12
|
+
# but still get the validity checks (trading permission, product support, order
|
|
13
|
+
# type, quantity > 0, market hours).
|
|
14
|
+
#
|
|
10
15
|
# @example Run equity risk checks
|
|
11
16
|
# DhanHQ::Risk::Pipeline.run!(
|
|
12
17
|
# instrument: instrument,
|
|
@@ -46,22 +51,25 @@ module DhanHQ
|
|
|
46
51
|
# Run all applicable risk checks.
|
|
47
52
|
#
|
|
48
53
|
# @param instrument [Object] instrument with trading metadata
|
|
49
|
-
# @param args [Hash] order arguments (string keys)
|
|
54
|
+
# @param args [Hash] order arguments (snake_case string keys)
|
|
50
55
|
# @param now [Time] current time for market hours check (default: Time.now)
|
|
51
56
|
# @param type [Symbol] :equity or :options
|
|
57
|
+
# @param positions [Array, nil] open positions; fetched once when nil
|
|
52
58
|
# @return [true] if all checks pass
|
|
53
59
|
# @raise [DhanHQ::RiskViolation] on first failure
|
|
54
|
-
def self.run!(instrument:, args:, now: Time.now, type: :equity)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
def self.run!(instrument:, args:, now: Time.now, type: :equity, positions: nil)
|
|
61
|
+
positions ||= DhanHQ::Models::Position.all
|
|
62
|
+
context = { instrument: instrument, args: args, now: now, positions: positions,
|
|
63
|
+
reducing: Exposure.reducing?(args, positions) }
|
|
64
|
+
|
|
65
|
+
run_checks!(CHECKS, context)
|
|
66
|
+
run_checks!(OPTION_CHECKS, context) if type == :options
|
|
67
|
+
run_checks!(DAILY_CHECKS, context)
|
|
58
68
|
true
|
|
59
69
|
end
|
|
60
70
|
|
|
61
|
-
def self.run_checks!(checks,
|
|
62
|
-
checks.each
|
|
63
|
-
check.run!(instrument: instrument, args: args, now: now)
|
|
64
|
-
end
|
|
71
|
+
def self.run_checks!(checks, context)
|
|
72
|
+
checks.each { |check| check.run!(**context) }
|
|
65
73
|
end
|
|
66
74
|
|
|
67
75
|
private_class_method :run_checks!
|
data/lib/DhanHQ/version.rb
CHANGED