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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +47 -1
- data/CHANGELOG.md +98 -0
- data/README.md +392 -6
- data/docs/AUTHENTICATION.md +3 -5
- data/docs/CONFIGURATION.md +3 -2
- data/docs/CONSTANTS_REFERENCE.md +8 -6
- 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/exe/dhanhq-mcp +7 -0
- 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 -212
- data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
- data/lib/DhanHQ/client.rb +58 -2
- data/lib/DhanHQ/concerns/order_audit.rb +74 -1
- data/lib/DhanHQ/configuration.rb +70 -1
- data/lib/DhanHQ/constants.rb +104 -2
- data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- 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/iceberg_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
- data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
- 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/mcp/server.rb +172 -9
- data/lib/DhanHQ/models/alert_order.rb +5 -2
- 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/instrument.rb +44 -14
- data/lib/DhanHQ/models/margin.rb +5 -1
- data/lib/DhanHQ/models/multi_order.rb +130 -0
- data/lib/DhanHQ/rate_limiter.rb +5 -3
- data/lib/DhanHQ/resources/alert_orders.rb +1 -0
- data/lib/DhanHQ/resources/forever_orders.rb +1 -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/iceberg_orders.rb +1 -0
- data/lib/DhanHQ/resources/multi_orders.rb +58 -0
- data/lib/DhanHQ/resources/orders.rb +2 -0
- data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
- data/lib/DhanHQ/resources/super_orders.rb +1 -0
- data/lib/DhanHQ/resources/twap_orders.rb +1 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/pipeline.rb +8 -1
- data/lib/DhanHQ/skills/base.rb +54 -3
- data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
- data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
- data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
- 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 +47 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
- metadata +41 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -48
- data/.rubocop_todo.yml +0 -217
- data/AGENTS.md +0 -23
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -14
- data/TAGS +0 -10
- data/core +0 -0
- data/diagram.html +0 -184
- data/skills/dhanhq-ruby/SKILL.md +0 -74
- data/skills/dhanhq-ruby/references/market_data.md +0 -3
- data/skills/dhanhq-ruby/references/orders.md +0 -7
- data/watchlist.csv +0 -3
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
data/lib/DhanHQ/risk/pipeline.rb
CHANGED
|
@@ -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
|
data/lib/DhanHQ/skills/base.rb
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|