DhanHQ 3.0.1 → 3.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +98 -0
  4. data/README.md +392 -6
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +8 -6
  8. data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
  9. data/docs/LIVE_ORDER_UPDATES.md +5 -10
  10. data/docs/RAILS_INTEGRATION.md +1 -1
  11. data/docs/RELEASE_GUIDE.md +13 -2
  12. data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
  13. data/docs/WEBSOCKET_INTEGRATION.md +13 -20
  14. data/docs/WEBSOCKET_PROTOCOL.md +7 -3
  15. data/exe/dhanhq-mcp +7 -0
  16. data/lib/DhanHQ/agent/key_coercion.rb +36 -0
  17. data/lib/DhanHQ/agent/tool.rb +37 -0
  18. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  19. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  20. data/lib/DhanHQ/agent/tool_registry.rb +17 -212
  21. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  22. data/lib/DhanHQ/client.rb +58 -2
  23. data/lib/DhanHQ/concerns/order_audit.rb +74 -1
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +104 -2
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  28. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  29. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  30. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  31. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  32. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  33. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
  34. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  35. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  36. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  37. data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
  38. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  39. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  40. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  41. data/lib/DhanHQ/mcp/server.rb +172 -9
  42. data/lib/DhanHQ/models/alert_order.rb +5 -2
  43. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  44. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  45. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  46. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  47. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  48. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  49. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  50. data/lib/DhanHQ/models/instrument.rb +44 -14
  51. data/lib/DhanHQ/models/margin.rb +5 -1
  52. data/lib/DhanHQ/models/multi_order.rb +130 -0
  53. data/lib/DhanHQ/rate_limiter.rb +5 -3
  54. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  55. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  56. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  57. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  58. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  59. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  60. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  61. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  62. data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
  63. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  64. data/lib/DhanHQ/resources/orders.rb +2 -0
  65. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  66. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  67. data/lib/DhanHQ/resources/twap_orders.rb +1 -0
  68. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  69. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  70. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  71. data/lib/DhanHQ/risk/pipeline.rb +8 -1
  72. data/lib/DhanHQ/skills/base.rb +54 -3
  73. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  74. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  75. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
  76. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  77. data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
  78. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  79. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  80. data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
  81. data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
  82. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  83. data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/write_paths.rb +57 -0
  86. data/lib/DhanHQ/ws/client.rb +117 -2
  87. data/lib/DhanHQ/ws/connection.rb +40 -11
  88. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  89. data/lib/dhan_hq.rb +47 -0
  90. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  91. metadata +41 -15
  92. data/.rspec +0 -3
  93. data/.rubocop.yml +0 -48
  94. data/.rubocop_todo.yml +0 -217
  95. data/AGENTS.md +0 -23
  96. data/CODE_OF_CONDUCT.md +0 -132
  97. data/Rakefile +0 -14
  98. data/TAGS +0 -10
  99. data/core +0 -0
  100. data/diagram.html +0 -184
  101. data/skills/dhanhq-ruby/SKILL.md +0 -74
  102. data/skills/dhanhq-ruby/references/market_data.md +0 -3
  103. data/skills/dhanhq-ruby/references/orders.md +0 -7
  104. data/watchlist.csv +0 -3
@@ -26,6 +26,7 @@ module DhanHQ
26
26
  # @return [Hash]
27
27
  def create(params)
28
28
  ensure_live_trading!
29
+ run_risk_checks!(params)
29
30
  log_order_context("DHAN_FOREVER_ORDER_ATTEMPT", params)
30
31
  post("/orders", params: params)
31
32
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for the Global Stocks fund limit.
7
+ #
8
+ # GET /v2/globalstocks/fundlimit
9
+ #
10
+ # Global Stocks funds are held in USD and are reported separately from the
11
+ # domestic INR fund limit exposed by {DhanHQ::Resources::Funds}.
12
+ class Funds < BaseAPI
13
+ API_TYPE = :order_api
14
+ HTTP_PATH = "/v2/globalstocks/fundlimit"
15
+
16
+ # Retrieves the authenticated user's US stock fund limit details.
17
+ #
18
+ # @return [Hash]
19
+ def fetch
20
+ get("")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Resources
5
+ module GlobalStocks
6
+ # Resource client for Global Stocks holdings.
7
+ #
8
+ # GET /v2/globalstocks/holdings
9
+ class Holdings < BaseAPI
10
+ API_TYPE = :order_api
11
+ HTTP_PATH = "/v2/globalstocks/holdings"
12
+
13
+ # Retrieves the authenticated user's US stock holdings.
14
+ #
15
+ # @return [Array<Hash>]
16
+ def all
17
+ get("")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -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
@@ -24,6 +24,7 @@ module DhanHQ
24
24
  # @return [Hash]
25
25
  def create(params)
26
26
  ensure_live_trading!
27
+ run_risk_checks!(params)
27
28
  log_order_context("DHAN_ICEBERG_ORDER_ATTEMPT", params)
28
29
  post("", params: params)
29
30
  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
@@ -17,6 +17,7 @@ module DhanHQ
17
17
 
18
18
  def create(params)
19
19
  ensure_live_trading!
20
+ run_risk_checks!(params)
20
21
  log_order_context("DHAN_ORDER_ATTEMPT", params)
21
22
  validate_place_order!(params)
22
23
  post("", params: params)
@@ -31,6 +32,7 @@ module DhanHQ
31
32
 
32
33
  def slicing(params)
33
34
  ensure_live_trading!
35
+ run_risk_checks!(params)
34
36
  log_order_context("DHAN_ORDER_SLICING_ATTEMPT", params)
35
37
  validate_place_order!(params)
36
38
  post("/slicing", params: params)
@@ -19,6 +19,7 @@ module DhanHQ
19
19
  # @return [Hash] API response containing pnlExitStatus and message.
20
20
  def configure(params)
21
21
  ensure_live_trading!
22
+ run_risk_checks!(params)
22
23
  log_order_context("DHAN_PNL_EXIT_CONFIGURE_ATTEMPT", params)
23
24
  post("", params: params)
24
25
  end
@@ -28,6 +28,7 @@ module DhanHQ
28
28
  # @return [Hash]
29
29
  def create(params)
30
30
  ensure_live_trading!
31
+ run_risk_checks!(params)
31
32
  log_order_context("DHAN_SUPER_ORDER_ATTEMPT", params)
32
33
  post("", params: params)
33
34
  end
@@ -24,6 +24,7 @@ module DhanHQ
24
24
  # @return [Hash]
25
25
  def create(params)
26
26
  ensure_live_trading!
27
+ run_risk_checks!(params)
27
28
  log_order_context("DHAN_TWAP_ORDER_ATTEMPT", params)
28
29
  post("", params: params)
29
30
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces maximum portfolio concentration in any single symbol.
7
+ class Concentration
8
+ MAX_CONCENTRATION_PCT = 25.0
9
+
10
+ def self.run!(args:, **_unused)
11
+ symbol = args["trading_symbol"] || args["security_id"]
12
+ return unless symbol
13
+
14
+ funds = DhanHQ::Models::Funds.fetch
15
+ available = funds.available_balance.to_f
16
+ return if available <= 0
17
+
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
23
+
24
+ current_exposure = symbol_positions.sum do |p|
25
+ p.net_qty.to_i.abs * p.cost_price.to_f
26
+ end
27
+
28
+ concentration_pct = (current_exposure / available) * 100.0
29
+ return if concentration_pct <= MAX_CONCENTRATION_PCT
30
+
31
+ raise DhanHQ::RiskViolation,
32
+ "Concentration #{concentration_pct.round(1)}% exceeds #{MAX_CONCENTRATION_PCT}% limit for #{symbol}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces daily maximum loss limit across all positions.
7
+ class MaxLoss
8
+ DAILY_MAX_LOSS = 50_000
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
14
+ end
15
+
16
+ return if total_unrealized_loss >= -DAILY_MAX_LOSS
17
+
18
+ raise DhanHQ::RiskViolation,
19
+ "Daily loss limit of ₹#{DAILY_MAX_LOSS} exceeded (current: ₹#{total_unrealized_loss.round(0)})"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DhanHQ
4
+ module Risk
5
+ module Checks
6
+ # Enforces maximum number of concurrent open positions.
7
+ class PositionLimits
8
+ MAX_OPEN_POSITIONS = 20
9
+
10
+ def self.run!(**_unused)
11
+ positions = DhanHQ::Models::Position.all
12
+ open_count = positions.count do |p|
13
+ p.net_qty.to_i != 0
14
+ end
15
+
16
+ return if open_count < MAX_OPEN_POSITIONS
17
+
18
+ raise DhanHQ::RiskViolation,
19
+ "Maximum #{MAX_OPEN_POSITIONS} open positions exceeded (#{open_count} open)"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -30,13 +30,19 @@ module DhanHQ
30
30
  Checks::ProductSupport,
31
31
  Checks::OrderType,
32
32
  Checks::Quantity,
33
- Checks::MarketHours
33
+ Checks::MarketHours,
34
+ Checks::PositionLimits,
35
+ Checks::Concentration
34
36
  ].freeze
35
37
 
36
38
  OPTION_CHECKS = [
37
39
  Checks::Options
38
40
  ].freeze
39
41
 
42
+ DAILY_CHECKS = [
43
+ Checks::MaxLoss
44
+ ].freeze
45
+
40
46
  # Run all applicable risk checks.
41
47
  #
42
48
  # @param instrument [Object] instrument with trading metadata
@@ -49,6 +55,7 @@ module DhanHQ
49
55
  def self.run!(instrument:, args:, now: Time.now, type: :equity)
50
56
  run_checks!(CHECKS, instrument, args, now)
51
57
  run_checks!(OPTION_CHECKS, instrument, args, now) if type == :options
58
+ run_checks!(DAILY_CHECKS, instrument, args, now)
52
59
  true
53
60
  end
54
61
  # rubocop:enable Naming/PredicateMethod
@@ -23,7 +23,7 @@ module DhanHQ
23
23
  # end
24
24
  #
25
25
  # def get_spot_price(ctx)
26
- # ctx[:spot_price] = ctx[:instrument].ltp[:ltp]
26
+ # ctx[:spot_price] = ctx[:instrument].ltp
27
27
  # ctx
28
28
  # end
29
29
  #
@@ -56,6 +56,28 @@ module DhanHQ
56
56
  @steps.sort_by! { |s| s[:priority] }
57
57
  end
58
58
 
59
+ # MCP risk level for this skill (defaults to the most conservative tier
60
+ # so a skill that forgets to declare one fails safe/write-gated).
61
+ #
62
+ # @param level [String, nil] one of read_only, trade_adjacent_read, live_write, destructive_write
63
+ def risk(level = nil)
64
+ level ? (@risk = level) : (@risk || "destructive_write")
65
+ end
66
+
67
+ # MCP policy scope required to invoke this skill.
68
+ #
69
+ # @param value [String, nil] e.g. "orders:read", "orders:write"
70
+ def scope(value = nil)
71
+ value ? (@scope = value) : (@scope || "orders:write")
72
+ end
73
+
74
+ # Human-readable description shown to MCP/LLM clients in tools/list.
75
+ #
76
+ # @param text [String, nil] one-line summary of what the skill does
77
+ def description(text = nil)
78
+ text ? (@description = text) : @description
79
+ end
80
+
59
81
  # Accessor for defined parameters.
60
82
  def params
61
83
  @params || {}
@@ -104,9 +126,9 @@ module DhanHQ
104
126
  self.class.name || self.class.to_s
105
127
  end
106
128
 
107
- # Skill description (override in subclasses).
129
+ # Skill description (declare via the class-level `description` macro; falls back to class name).
108
130
  def description
109
- self.class.to_s
131
+ self.class.description || self.class.to_s
110
132
  end
111
133
 
112
134
  # List of parameter definitions for this skill.
@@ -116,6 +138,35 @@ module DhanHQ
116
138
 
117
139
  private
118
140
 
141
+ # Real DhanHQ::Models::OptionChain#fetch shape: { last_price:, strikes: [{ strike:, call: {...}, put: {...} }] }.
142
+ # Nearest strike to a target price — always returns an entry (never nil) unless the chain is empty.
143
+ def nearest_strike(chain, target_price)
144
+ strikes = chain[:strikes]
145
+ return nil if strikes.nil? || strikes.empty?
146
+
147
+ strikes.min_by { |s| (s[:strike].to_f - target_price.to_f).abs }
148
+ end
149
+
150
+ # Exact strike match within tolerance — nil if no strike sits on that price.
151
+ def find_strike(chain, target_price, tolerance: 0.001)
152
+ strikes = chain[:strikes]
153
+ return nil if strikes.nil? || strikes.empty?
154
+
155
+ strikes.find { |s| (s[:strike].to_f - target_price.to_f).abs < tolerance }
156
+ end
157
+
158
+ def leg_side(strike_entry, option_type)
159
+ option_type == "CE" ? strike_entry[:call] : strike_entry[:put]
160
+ end
161
+
162
+ def leg_security_id(strike_entry, option_type)
163
+ leg_side(strike_entry, option_type)[:security_id]
164
+ end
165
+
166
+ def leg_premium(strike_entry, option_type)
167
+ leg_side(strike_entry, option_type)[:last_price]
168
+ end
169
+
119
170
  def build_context(args)
120
171
  ctx = {}
121
172