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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module DryRun
|
|
7
|
+
# Remembers the orders a dry run pretended to place, so a follow-up read for one
|
|
8
|
+
# of those simulated ids can be answered locally.
|
|
9
|
+
#
|
|
10
|
+
# This exists because the high-level models re-fetch after placing:
|
|
11
|
+
# `Models::Order.place` and `GlobalStocks::Order.place` both call `find(order_id)`
|
|
12
|
+
# to return the complete record. Without a ledger that GET would leave the process
|
|
13
|
+
# carrying a `DRYRUN-…` id to the live API, which is both a real network call and a
|
|
14
|
+
# guaranteed miss — so dry run would only have worked for direct
|
|
15
|
+
# {DhanHQ::Client#request} calls, not the API people actually use.
|
|
16
|
+
#
|
|
17
|
+
# Entries are per-process and capped; a long rehearsal evicts its oldest records
|
|
18
|
+
# rather than growing without bound.
|
|
19
|
+
class Ledger
|
|
20
|
+
# Maximum simulated orders retained before the oldest are evicted.
|
|
21
|
+
MAX_ENTRIES = 500
|
|
22
|
+
|
|
23
|
+
def initialize(max_entries: MAX_ENTRIES)
|
|
24
|
+
@max_entries = max_entries
|
|
25
|
+
@records = {}
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Process-wide ledger used by {DhanHQ::DryRun::Simulator}.
|
|
30
|
+
#
|
|
31
|
+
# @return [Ledger]
|
|
32
|
+
def self.instance
|
|
33
|
+
@instance ||= new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Records a simulated order.
|
|
37
|
+
#
|
|
38
|
+
# @param order_id [String] The simulated order id
|
|
39
|
+
# @param record [Hash] The response to replay for reads of that id
|
|
40
|
+
# @return [Hash] The stored record
|
|
41
|
+
def record(order_id, record)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@records.delete(order_id)
|
|
44
|
+
@records[order_id] = record
|
|
45
|
+
@records.shift while @records.size > @max_entries
|
|
46
|
+
record
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fetches a previously simulated order.
|
|
51
|
+
#
|
|
52
|
+
# @param order_id [String]
|
|
53
|
+
# @return [Hash, nil] nil when the id was never simulated in this process
|
|
54
|
+
def fetch(order_id)
|
|
55
|
+
@mutex.synchronize { @records[order_id] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Integer] Number of retained records
|
|
59
|
+
def size
|
|
60
|
+
@mutex.synchronize { @records.size }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Drops every retained record. Intended for test isolation.
|
|
64
|
+
#
|
|
65
|
+
# @return [void]
|
|
66
|
+
def clear
|
|
67
|
+
@mutex.synchronize { @records.clear }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
+
|
|
6
|
+
module DhanHQ
|
|
7
|
+
module DryRun
|
|
8
|
+
# Answers state-changing requests locally instead of sending them.
|
|
9
|
+
#
|
|
10
|
+
# Reads still go to the API, so market data, the option chain, positions and
|
|
11
|
+
# holdings stay live and a strategy can be rehearsed against real prices. The one
|
|
12
|
+
# exception is a read for an order this simulator itself invented: those carry the
|
|
13
|
+
# {ID_PREFIX} sentinel, can only exist because of a dry run, and are replayed from
|
|
14
|
+
# the {Ledger} so the models' place-then-refetch flow completes without touching
|
|
15
|
+
# the network.
|
|
16
|
+
#
|
|
17
|
+
# Responses are shaped per endpoint family, because callers inspect them: order
|
|
18
|
+
# placements need an `orderId` for the models to treat the write as successful, and
|
|
19
|
+
# the basket endpoint needs an `orders` array or {DhanHQ::Models::MultiOrder} sees
|
|
20
|
+
# a failure.
|
|
21
|
+
class Simulator
|
|
22
|
+
# Marks an order id as fabricated by a dry run.
|
|
23
|
+
ID_PREFIX = "DRYRUN-"
|
|
24
|
+
|
|
25
|
+
# Extracts a fabricated id out of a request path.
|
|
26
|
+
ID_PATTERN = /#{Regexp.escape(ID_PREFIX)}[A-Za-z0-9]+/
|
|
27
|
+
|
|
28
|
+
def initialize(ledger: Ledger.instance)
|
|
29
|
+
@ledger = ledger
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether this request should be answered locally.
|
|
33
|
+
#
|
|
34
|
+
# @param method [Symbol] HTTP method
|
|
35
|
+
# @param path [String, nil] Request path
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def simulates?(method, path)
|
|
38
|
+
return false unless DhanHQ.configuration&.dry_run?
|
|
39
|
+
|
|
40
|
+
WritePaths.mutating?(method, path) || replayable_read?(method, path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Builds — and logs — the response for a simulated request.
|
|
44
|
+
#
|
|
45
|
+
# @param method [Symbol] HTTP method
|
|
46
|
+
# @param path [String] Request path
|
|
47
|
+
# @param payload [Hash] The request body that will not be sent
|
|
48
|
+
# @return [ActiveSupport::HashWithIndifferentAccess]
|
|
49
|
+
def response_for(method, path, payload)
|
|
50
|
+
return replay(path) if replayable_read?(method, path)
|
|
51
|
+
|
|
52
|
+
log(method, path, payload)
|
|
53
|
+
body = simulated_body(method, path, payload)
|
|
54
|
+
body.merge("dryRun" => true, "method" => method.to_s.upcase, "path" => path)
|
|
55
|
+
.with_indifferent_access
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# A GET for an id this simulator minted. Only reachable in dry run, so
|
|
61
|
+
# intercepting it never shadows a real read.
|
|
62
|
+
def replayable_read?(method, path)
|
|
63
|
+
method == :get && path.to_s.include?(ID_PREFIX)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def replay(path)
|
|
67
|
+
order_id = path.to_s[ID_PATTERN]
|
|
68
|
+
record = @ledger.fetch(order_id)
|
|
69
|
+
return record.with_indifferent_access if record
|
|
70
|
+
|
|
71
|
+
# Simulated in another process, or evicted from the ledger. Answer with the
|
|
72
|
+
# minimum a caller needs rather than falling through to a live request.
|
|
73
|
+
{ "orderId" => order_id, "orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT, "dryRun" => true }
|
|
74
|
+
.with_indifferent_access
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def simulated_body(method, path, payload)
|
|
78
|
+
if WritePaths.multi_order?(path)
|
|
79
|
+
simulated_basket(payload)
|
|
80
|
+
elsif WritePaths.order_placement?(method, path)
|
|
81
|
+
simulated_order(payload)
|
|
82
|
+
else
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Echoes the submitted fields back alongside the fabricated id, so a model built
|
|
88
|
+
# from the ledger record looks like the order that would have been placed.
|
|
89
|
+
def simulated_order(payload)
|
|
90
|
+
order_id = generate_id
|
|
91
|
+
record = stringify(payload).merge(
|
|
92
|
+
"orderId" => order_id,
|
|
93
|
+
"orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT,
|
|
94
|
+
"dryRun" => true
|
|
95
|
+
)
|
|
96
|
+
@ledger.record(order_id, record)
|
|
97
|
+
{ "orderId" => order_id, "orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One result per submitted leg, keyed by the caller's sequence, mirroring the
|
|
101
|
+
# real basket response.
|
|
102
|
+
def simulated_basket(payload)
|
|
103
|
+
legs = stringify(payload)["orders"]
|
|
104
|
+
legs = [] unless legs.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
orders = legs.each_with_index.map do |leg, index|
|
|
107
|
+
order_id = generate_id
|
|
108
|
+
sequence = (leg.is_a?(Hash) ? leg["sequence"] : nil) || (index + 1).to_s
|
|
109
|
+
@ledger.record(order_id, (leg.is_a?(Hash) ? leg : {}).merge("orderId" => order_id, "dryRun" => true))
|
|
110
|
+
{
|
|
111
|
+
"orderId" => order_id,
|
|
112
|
+
"sequence" => sequence,
|
|
113
|
+
"orderStatus" => DhanHQ::Constants::OrderStatus::TRANSIT
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
{ "orders" => orders }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def generate_id
|
|
121
|
+
"#{ID_PREFIX}#{SecureRandom.hex(6).upcase}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def stringify(payload)
|
|
125
|
+
payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : {}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def log(method, path, payload)
|
|
129
|
+
DhanHQ.logger&.warn(
|
|
130
|
+
JSON.generate(
|
|
131
|
+
event: "DHAN_DRY_RUN",
|
|
132
|
+
method: method.to_s.upcase,
|
|
133
|
+
path: path,
|
|
134
|
+
payload: payload.is_a?(Hash) ? payload : nil
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -11,6 +11,29 @@ module DhanHQ
|
|
|
11
11
|
hash.transform_keys { |key| key.to_s.camelize(:lower) }
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# Recursively convert keys from snake_case to camelCase, descending into nested
|
|
15
|
+
# hashes and through arrays.
|
|
16
|
+
#
|
|
17
|
+
# {#camelize_keys} only rewrites the top level, which is correct for the flat
|
|
18
|
+
# payloads most endpoints take. Endpoints whose body nests objects — the basket
|
|
19
|
+
# order `orders` array, an alert's `condition` — need this instead, or the nested
|
|
20
|
+
# fields reach the API still in snake_case and are rejected.
|
|
21
|
+
#
|
|
22
|
+
# @param value [Hash, Array, Object] The value to convert
|
|
23
|
+
# @return [Hash, Array, Object] The converted value; non-collections pass through
|
|
24
|
+
def deep_camelize_keys(value)
|
|
25
|
+
case value
|
|
26
|
+
when Hash
|
|
27
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
28
|
+
out[key.to_s.camelize(:lower)] = deep_camelize_keys(nested)
|
|
29
|
+
end
|
|
30
|
+
when Array
|
|
31
|
+
value.map { |nested| deep_camelize_keys(nested) }
|
|
32
|
+
else
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
14
37
|
# Convert keys from snake_case to TitleCase
|
|
15
38
|
#
|
|
16
39
|
# @param hash [Hash] The hash to convert
|
data/lib/DhanHQ/mcp/server.rb
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "timeout"
|
|
4
5
|
require_relative "../agent"
|
|
6
|
+
require_relative "../ai"
|
|
5
7
|
|
|
6
8
|
module DhanHQ
|
|
7
9
|
module MCP
|
|
8
10
|
# Minimal MCP-compatible stdio JSON-RPC server for DhanHQ agent tools.
|
|
9
11
|
class Server
|
|
10
|
-
|
|
12
|
+
# Raised for an unrecognized top-level JSON-RPC method (maps to -32601).
|
|
13
|
+
class UnknownMethodError < StandardError; end
|
|
14
|
+
# Raised for a well-formed request with invalid/unknown params (maps to -32602).
|
|
15
|
+
class InvalidParamsError < StandardError; end
|
|
16
|
+
|
|
17
|
+
SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05"].freeze
|
|
18
|
+
DEFAULT_TOOL_CALL_TIMEOUT_SECONDS = 15
|
|
19
|
+
|
|
20
|
+
RESOURCES = [
|
|
21
|
+
{ uri: "dhanhq://account/profile", name: "Dhan Profile", description: "Current account profile including client ID, PAN, name, and trading permissions",
|
|
22
|
+
mimeType: "application/json" },
|
|
23
|
+
{ uri: "dhanhq://account/funds", name: "Fund Limits", description: "Available balance, used margin, and withdrawal capacity", mimeType: "application/json" },
|
|
24
|
+
{ uri: "dhanhq://account/holdings", name: "Portfolio Holdings", description: "Current equity holdings with quantity, average price, and P&L", mimeType: "application/json" },
|
|
25
|
+
{ uri: "dhanhq://account/positions", name: "Open Positions", description: "Current F&O and equity positions with net quantity and unrealized P&L",
|
|
26
|
+
mimeType: "application/json" },
|
|
27
|
+
{ uri: "dhanhq://account/orders", name: "Recent Orders", description: "Recent order history with status, quantity, and fill details", mimeType: "application/json" },
|
|
28
|
+
{ uri: "dhanhq://market/capabilities", name: "Agent Capabilities", description: "All available tools, scopes, risk levels, and version info", mimeType: "application/json" }
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
PROMPTS = [
|
|
32
|
+
{ name: "portfolio_summary", description: "Generate a human-readable summary of your current portfolio, positions, and available funds" },
|
|
33
|
+
{ name: "market_analysis", description: "Analyze current market conditions and generate a trading context summary" },
|
|
34
|
+
{ name: "risk_report", description: "Review current risk exposure including open positions, P&L, and position limits" },
|
|
35
|
+
{ name: "suggest_strategy", description: "Suggest a trading strategy based on current portfolio and market conditions" },
|
|
36
|
+
{ name: "order_preview", description: "Preview an order before placing it with risk validation" }
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(input: $stdin, output: $stdout, policy: DhanHQ::Agent::Policy.from_env,
|
|
40
|
+
tool_call_timeout: DEFAULT_TOOL_CALL_TIMEOUT_SECONDS)
|
|
11
41
|
@input = input
|
|
12
42
|
@output = output
|
|
13
43
|
@policy = policy
|
|
44
|
+
@tool_call_timeout = tool_call_timeout
|
|
14
45
|
end
|
|
15
46
|
|
|
16
47
|
def run
|
|
@@ -19,32 +50,164 @@ module DhanHQ
|
|
|
19
50
|
|
|
20
51
|
def handle_line(line)
|
|
21
52
|
request = JSON.parse(line)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
53
|
+
rescue JSON::ParserError => e
|
|
54
|
+
respond(nil, nil, code: -32_700, message: "Parse error: #{e.message}")
|
|
55
|
+
else
|
|
56
|
+
handle_request(request)
|
|
25
57
|
end
|
|
26
58
|
|
|
27
59
|
private
|
|
28
60
|
|
|
61
|
+
def handle_request(request)
|
|
62
|
+
return unless request.key?("id") # JSON-RPC notification — must not receive a response
|
|
63
|
+
|
|
64
|
+
id = request["id"]
|
|
65
|
+
respond(id, dispatch(request["method"], request["params"] || {}))
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
respond(id, nil, code: error_code_for(e), message: e.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def error_code_for(error)
|
|
71
|
+
case error
|
|
72
|
+
when UnknownMethodError then -32_601
|
|
73
|
+
when InvalidParamsError then -32_602
|
|
74
|
+
else -32_603
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
29
78
|
def dispatch(method, params)
|
|
30
79
|
case method
|
|
31
80
|
when "initialize"
|
|
32
81
|
{
|
|
33
|
-
protocolVersion: "
|
|
82
|
+
protocolVersion: negotiate_protocol_version(params["protocolVersion"]),
|
|
34
83
|
serverInfo: { name: "dhanhq-ruby", version: DhanHQ::VERSION },
|
|
35
|
-
capabilities: { tools: {} }
|
|
84
|
+
capabilities: { tools: {}, resources: {}, prompts: {} }
|
|
36
85
|
}
|
|
37
86
|
when "tools/list"
|
|
38
87
|
{ tools: DhanHQ::Agent::ToolRegistry.list.map { |t| mcp_tool(t) } }
|
|
39
88
|
when "tools/call"
|
|
40
|
-
|
|
89
|
+
{ content: [{ type: "text", text: JSON.pretty_generate(serialize(call_tool(params))) }] }
|
|
90
|
+
when "resources/list"
|
|
91
|
+
{ resources: resource_definitions }
|
|
92
|
+
when "resources/read"
|
|
93
|
+
resource = resources.find { |r| r[:uri] == params["uri"] }
|
|
94
|
+
raise InvalidParamsError, "Unknown resource: #{params["uri"]}" unless resource
|
|
95
|
+
|
|
96
|
+
{ contents: [resource_read(resource)] }
|
|
97
|
+
when "prompts/list"
|
|
98
|
+
{ prompts: prompt_definitions }
|
|
99
|
+
when "prompts/get"
|
|
100
|
+
prompt = prompts.find { |p| p[:name] == params["name"] }
|
|
101
|
+
raise InvalidParamsError, "Unknown prompt: #{params["name"]}" unless prompt
|
|
102
|
+
|
|
103
|
+
prompt_result(prompt, params.fetch("arguments", {}))
|
|
104
|
+
else
|
|
105
|
+
raise UnknownMethodError, "Unsupported MCP method: #{method}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def negotiate_protocol_version(requested)
|
|
110
|
+
SUPPORTED_PROTOCOL_VERSIONS.include?(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS.last
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def call_tool(params)
|
|
114
|
+
Timeout.timeout(@tool_call_timeout) do
|
|
115
|
+
DhanHQ::Agent::ToolRegistry.execute(
|
|
41
116
|
params.fetch("name"),
|
|
42
117
|
params.fetch("arguments", {}),
|
|
43
118
|
policy: @policy
|
|
44
119
|
)
|
|
45
|
-
|
|
120
|
+
end
|
|
121
|
+
rescue ArgumentError => e
|
|
122
|
+
raise InvalidParamsError, e.message
|
|
123
|
+
rescue Timeout::Error
|
|
124
|
+
raise "Tool call '#{params["name"]}' timed out after #{@tool_call_timeout}s " \
|
|
125
|
+
"(likely blocked on rate-limit backoff) — retry shortly"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resources
|
|
129
|
+
RESOURCES
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def prompts
|
|
133
|
+
PROMPTS
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def resource_definitions
|
|
137
|
+
RESOURCES.map { |r| r.except(:handler) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resource_read(resource)
|
|
141
|
+
handler = resource_handler(resource[:uri])
|
|
142
|
+
data = handler.call
|
|
143
|
+
{ uri: resource[:uri], mimeType: "application/json", text: JSON.pretty_generate(serialize(data)) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resource_handler(uri)
|
|
147
|
+
case uri
|
|
148
|
+
when "dhanhq://account/profile" then -> { DhanHQ::Models::Profile.fetch }
|
|
149
|
+
when "dhanhq://account/funds" then -> { DhanHQ::Models::Funds.fetch }
|
|
150
|
+
when "dhanhq://account/holdings" then -> { DhanHQ::Models::Holding.all }
|
|
151
|
+
when "dhanhq://account/positions" then -> { DhanHQ::Models::Position.all }
|
|
152
|
+
when "dhanhq://account/orders" then -> { DhanHQ::Models::Order.all }
|
|
153
|
+
when "dhanhq://market/capabilities" then -> { DhanHQ::Agent::ToolRegistry.capabilities }
|
|
154
|
+
else raise ArgumentError, "No handler for resource: #{uri}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def prompt_definitions
|
|
159
|
+
PROMPTS.map { |p| { name: p[:name], description: p[:description], arguments: prompt_arguments(p[:name]) } }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def prompt_arguments(prompt_name)
|
|
163
|
+
case prompt_name
|
|
164
|
+
when "order_preview"
|
|
165
|
+
[{ name: "transaction_type", description: "BUY or SELL", required: true },
|
|
166
|
+
{ name: "security_id", description: "Dhan security ID", required: true },
|
|
167
|
+
{ name: "quantity", description: "Number of shares/lots", required: true },
|
|
168
|
+
{ name: "exchange_segment", description: "NSE_EQ, NSE_FNO, etc.", required: true },
|
|
169
|
+
{ name: "price", description: "Limit price (optional for MARKET)", required: false }]
|
|
170
|
+
when "market_analysis"
|
|
171
|
+
[{ name: "symbol", description: "Ticker symbol (e.g., NIFTY, RELIANCE)", required: false }]
|
|
172
|
+
else
|
|
173
|
+
[]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def prompt_result(prompt, arguments)
|
|
178
|
+
case prompt[:name]
|
|
179
|
+
when "portfolio_summary"
|
|
180
|
+
holdings = DhanHQ::Models::Holding.all
|
|
181
|
+
positions = DhanHQ::Models::Position.all
|
|
182
|
+
funds = DhanHQ::Models::Funds.fetch
|
|
183
|
+
summary = DhanHQ::AI::PromptHelpers.portfolio_summary(holdings: holdings, positions: positions, funds: funds)
|
|
184
|
+
{ messages: [{ role: "user", content: { type: "text", text: summary } }] }
|
|
185
|
+
when "market_analysis"
|
|
186
|
+
symbol = arguments["symbol"] || "NIFTY"
|
|
187
|
+
instrument = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, symbol)
|
|
188
|
+
security_id = instrument&.security_id&.to_i
|
|
189
|
+
if security_id
|
|
190
|
+
snapshot = DhanHQ::Models::MarketFeed.quote(DhanHQ::Constants::ExchangeSegment::IDX_I => [security_id])
|
|
191
|
+
text = "Market snapshot for #{symbol}: #{snapshot}"
|
|
192
|
+
else
|
|
193
|
+
text = "Could not resolve symbol #{symbol} to a security ID for market data."
|
|
194
|
+
end
|
|
195
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
196
|
+
when "risk_report"
|
|
197
|
+
positions = DhanHQ::Models::Position.all
|
|
198
|
+
text = DhanHQ::AI::PromptHelpers.risk_report(positions: positions)
|
|
199
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
200
|
+
when "order_preview"
|
|
201
|
+
preview = DhanHQ::Agent::OrderPreview.new(arguments)
|
|
202
|
+
text = preview.valid? ? "Order preview: #{preview.to_h[:summary]}" : "Validation errors: #{preview.errors.join(", ")}"
|
|
203
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
204
|
+
when "suggest_strategy"
|
|
205
|
+
positions = DhanHQ::Models::Position.all
|
|
206
|
+
funds = DhanHQ::Models::Funds.fetch
|
|
207
|
+
text = "Current portfolio: #{positions.size} open positions, Available: ₹#{funds.available_balance}. Review positions for strategy suggestions."
|
|
208
|
+
{ messages: [{ role: "user", content: { type: "text", text: text } }] }
|
|
46
209
|
else
|
|
47
|
-
raise ArgumentError, "
|
|
210
|
+
raise ArgumentError, "Unknown prompt: #{prompt[:name]}"
|
|
48
211
|
end
|
|
49
212
|
end
|
|
50
213
|
|
|
@@ -45,7 +45,10 @@ module DhanHQ
|
|
|
45
45
|
def create(params)
|
|
46
46
|
normalized = snake_case(params)
|
|
47
47
|
validate_params!(normalized, DhanHQ::Contracts::AlertOrderContract)
|
|
48
|
-
|
|
48
|
+
# `condition` and every entry of `orders` are nested objects whose fields the
|
|
49
|
+
# API expects in camelCase (AlertCondition#comparisonType, #securityId, …), so
|
|
50
|
+
# a shallow camelize would leave them snake_cased on the wire.
|
|
51
|
+
response = resource.create(deep_camelize_keys(normalized))
|
|
49
52
|
return nil unless response.is_a?(Hash) && response["alertId"]
|
|
50
53
|
|
|
51
54
|
find(response["alertId"])
|
|
@@ -69,7 +72,7 @@ module DhanHQ
|
|
|
69
72
|
normalized = snake_case(params)
|
|
70
73
|
validate_params!(normalized, DhanHQ::Contracts::AlertOrderContract)
|
|
71
74
|
payload = normalized.merge(alert_id: alert_id)
|
|
72
|
-
response = resource.update(alert_id,
|
|
75
|
+
response = resource.update(alert_id, deep_camelize_keys(payload))
|
|
73
76
|
return nil unless success_response?(response)
|
|
74
77
|
|
|
75
78
|
find(alert_id)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Models
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
##
|
|
7
|
+
# USD fund limit for Global Stocks trading.
|
|
8
|
+
#
|
|
9
|
+
# This is a separate balance from the domestic INR limit exposed by
|
|
10
|
+
# {DhanHQ::Models::Funds}.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# funds = DhanHQ::Models::GlobalStocks::Funds.fetch
|
|
14
|
+
# puts "Available: $#{funds.available_cash}"
|
|
15
|
+
# puts "Unsettled: $#{funds.unsettled_cash}"
|
|
16
|
+
#
|
|
17
|
+
class Funds < BaseModel
|
|
18
|
+
HTTP_PATH = "/v2/globalstocks/fundlimit"
|
|
19
|
+
|
|
20
|
+
attributes :dhan_client_id, :available_cash, :cash_on_account, :actual_cash,
|
|
21
|
+
:settled_cash, :unsettled_cash, :margin_utilized
|
|
22
|
+
|
|
23
|
+
# Returns a concise prompt-friendly summary of the USD balance.
|
|
24
|
+
def to_prompt
|
|
25
|
+
parts = []
|
|
26
|
+
parts << "available=$#{available_cash}" if available_cash
|
|
27
|
+
parts << "settled=$#{settled_cash}" if settled_cash
|
|
28
|
+
parts << "unsettled=$#{unsettled_cash}" if unsettled_cash
|
|
29
|
+
parts << "utilized=$#{margin_utilized}" if margin_utilized&.positive?
|
|
30
|
+
parts.join(", ")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# @return [DhanHQ::Resources::GlobalStocks::Funds]
|
|
35
|
+
def resource
|
|
36
|
+
@resource ||= DhanHQ::Resources::GlobalStocks::Funds.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fetches the Global Stocks fund limit.
|
|
40
|
+
#
|
|
41
|
+
# @return [Funds]
|
|
42
|
+
def fetch
|
|
43
|
+
new(resource.fetch, skip_validation: true)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Convenience accessor for the available USD cash.
|
|
47
|
+
#
|
|
48
|
+
# @return [Float]
|
|
49
|
+
def balance
|
|
50
|
+
fetch.available_cash.to_f
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Models
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
##
|
|
7
|
+
# A single Global Stocks (US equity) holding.
|
|
8
|
+
#
|
|
9
|
+
# Unlike domestic holdings, US holdings report live valuation directly
|
|
10
|
+
# (+ltp+, +current_value+, +gain_value+), so no quote lookup is needed to
|
|
11
|
+
# compute unrealised P&L.
|
|
12
|
+
#
|
|
13
|
+
# @example Portfolio with unrealised P&L
|
|
14
|
+
# holdings = DhanHQ::Models::GlobalStocks::Holding.all
|
|
15
|
+
# holdings.each do |h|
|
|
16
|
+
# puts "#{h.trading_symbol}: #{h.quantity} @ $#{h.avg_cost_price} (#{h.gain_percentage}%)"
|
|
17
|
+
# end
|
|
18
|
+
# puts "Total: $#{DhanHQ::Models::GlobalStocks::Holding.total_current_value}"
|
|
19
|
+
#
|
|
20
|
+
class Holding < BaseModel
|
|
21
|
+
HTTP_PATH = "/v2/globalstocks/holdings"
|
|
22
|
+
|
|
23
|
+
attributes :dhan_client_id, :trading_symbol, :display_name, :security_id,
|
|
24
|
+
:exchange, :quantity, :avg_cost_price, :cost_value, :current_value,
|
|
25
|
+
:gain_value, :ltp, :prev_close, :long_term_flag, :last_updated
|
|
26
|
+
|
|
27
|
+
# Returns a concise prompt-friendly summary of the holding.
|
|
28
|
+
def to_prompt
|
|
29
|
+
parts = ["#{quantity}x #{trading_symbol || security_id}"]
|
|
30
|
+
parts << "avg_cost=$#{avg_cost_price}" if avg_cost_price&.positive?
|
|
31
|
+
parts << "ltp=$#{ltp}" if ltp&.positive?
|
|
32
|
+
parts << "value=$#{current_value}" if current_value
|
|
33
|
+
parts << "pnl=$#{gain_value} (#{gain_percentage}%)" if gain_value
|
|
34
|
+
parts.join(", ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Unrealised gain as a percentage of cost.
|
|
38
|
+
#
|
|
39
|
+
# @return [Float] 0.0 when cost value is unknown or zero.
|
|
40
|
+
def gain_percentage
|
|
41
|
+
cost = cost_value.to_f
|
|
42
|
+
return 0.0 if cost.zero?
|
|
43
|
+
|
|
44
|
+
(gain_value.to_f / cost * 100).round(2)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] True when the position is in profit.
|
|
48
|
+
def profitable?
|
|
49
|
+
gain_value.to_f.positive?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] True for a fractional-share position.
|
|
53
|
+
def fractional?
|
|
54
|
+
quantity.is_a?(Float) && (quantity % 1 != 0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Change against the previous close, per share.
|
|
58
|
+
#
|
|
59
|
+
# @return [Float, nil] nil when either price is unavailable.
|
|
60
|
+
def day_change
|
|
61
|
+
return nil unless ltp && prev_close
|
|
62
|
+
|
|
63
|
+
(ltp.to_f - prev_close.to_f).round(4)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# @return [DhanHQ::Resources::GlobalStocks::Holdings]
|
|
68
|
+
def resource
|
|
69
|
+
@resource ||= DhanHQ::Resources::GlobalStocks::Holdings.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Retrieves all Global Stocks holdings.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Holding>] Empty array when there are no US holdings.
|
|
75
|
+
def all
|
|
76
|
+
response = resource.all
|
|
77
|
+
return [] unless response.is_a?(Array)
|
|
78
|
+
|
|
79
|
+
response.map { |holding| new(holding, skip_validation: true) }
|
|
80
|
+
rescue DhanHQ::NoHoldingsError
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Total market value of the US portfolio.
|
|
85
|
+
#
|
|
86
|
+
# @return [Float]
|
|
87
|
+
def total_current_value
|
|
88
|
+
all.sum { |holding| holding.current_value.to_f }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Total unrealised P&L across the US portfolio.
|
|
92
|
+
#
|
|
93
|
+
# @return [Float]
|
|
94
|
+
def total_gain
|
|
95
|
+
all.sum { |holding| holding.gain_value.to_f }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|