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.
@@ -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 = resolved_access_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 = current_config&.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 = current_config&.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 && !payload.key?(:dhanClientId) && !payload.key?("dhanClientId")
96
- out = payload.dup
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
- post("/multi", params: params)
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
- def self.run!(instrument:, **_unused)
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
- def self.run!(args:, **_unused)
11
- symbol = args["trading_symbol"] || args["security_id"]
12
- return unless symbol
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 = DhanHQ::Models::Position.all
19
- symbol_positions = positions.select do |p|
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 <= MAX_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 #{MAX_CONCENTRATION_PCT}% limit for #{symbol}"
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
- def self.run!(**_unused)
11
- positions = DhanHQ::Models::Position.all
12
- total_unrealized_loss = positions.sum do |p|
13
- p.unrealized_profit.to_f
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 total_unrealized_loss >= -DAILY_MAX_LOSS
21
+ return if day_pnl >= -limit
17
22
 
18
23
  raise DhanHQ::RiskViolation,
19
- "Daily loss limit of ₹#{DAILY_MAX_LOSS} exceeded (current: ₹#{total_unrealized_loss.round(0)})"
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
- def self.run!(args:, instrument:, **_unused)
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
- VALID_TYPES = %w[MARKET LIMIT].freeze
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
- positions = DhanHQ::Models::Position.all
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 < MAX_OPEN_POSITIONS
19
+ return if open_count < limit
17
20
 
18
21
  raise DhanHQ::RiskViolation,
19
- "Maximum #{MAX_OPEN_POSITIONS} open positions exceeded (#{open_count} open)"
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
- def self.run!(args:, **_unused)
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
- raise DhanHQ::RiskViolation, "Quantity exceeds limit" if quantity > MAX_QUANTITY
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
@@ -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
- run_checks!(CHECKS, instrument, args, now)
56
- run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
57
- run_checks!(DAILY_CHECKS, instrument, args, now)
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, instrument, args, now)
62
- checks.each do |check|
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!
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DhanHQ
4
4
  # Semantic version of the DhanHQ client gem.
5
- VERSION = "4.1.0"
5
+ VERSION = "4.1.1"
6
6
  end