DhanHQ 3.1.0 → 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 +47 -0
- data/README.md +241 -3
- data/docs/AUTHENTICATION.md +3 -5
- data/docs/CONFIGURATION.md +3 -2
- data/docs/CONSTANTS_REFERENCE.md +5 -4
- 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/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 -261
- data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
- data/lib/DhanHQ/client.rb +58 -2
- data/lib/DhanHQ/concerns/order_audit.rb +35 -4
- data/lib/DhanHQ/configuration.rb +70 -1
- data/lib/DhanHQ/constants.rb +101 -0
- data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
- 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/multi_order_contract.rb +74 -0
- data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
- 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/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/margin.rb +5 -1
- data/lib/DhanHQ/models/multi_order.rb +130 -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/multi_orders.rb +58 -0
- 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/dhanhq/analysis/options_buying_advisor.rb +11 -10
- metadata +30 -40
- data/.rspec +0 -3
- data/.rubocop.yml +0 -50
- 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 -207
- data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +0 -54
- data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +0 -65
- data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +0 -89
- data/skills/dhanhq-ruby/examples/iron_condor.rb +0 -137
- data/skills/dhanhq-ruby/examples/live_feed_setup.rb +0 -43
- data/skills/dhanhq-ruby/examples/margin_check.rb +0 -42
- data/skills/dhanhq-ruby/examples/order_management.rb +0 -105
- data/skills/dhanhq-ruby/examples/place_equity_order.rb +0 -36
- data/skills/dhanhq-ruby/examples/place_fno_order.rb +0 -76
- data/skills/dhanhq-ruby/examples/portfolio_summary.rb +0 -74
- data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +0 -57
- data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +0 -65
- data/skills/dhanhq-ruby/references/common-workflows.md +0 -76
- data/skills/dhanhq-ruby/references/error-codes.md +0 -50
- data/skills/dhanhq-ruby/references/funds.md +0 -67
- data/skills/dhanhq-ruby/references/instruments.md +0 -85
- data/skills/dhanhq-ruby/references/live-feed.md +0 -83
- data/skills/dhanhq-ruby/references/market-data.md +0 -119
- data/skills/dhanhq-ruby/references/option-chain.md +0 -71
- data/skills/dhanhq-ruby/references/options-analysis-patterns.md +0 -76
- data/skills/dhanhq-ruby/references/orders.md +0 -201
- data/skills/dhanhq-ruby/references/portfolio.md +0 -93
- data/skills/dhanhq-ruby/references/scanx-data.md +0 -62
- data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +0 -323
- data/skills/dhanhq-ruby/scripts/resolve_security.rb +0 -168
- data/skills/dhanhq-ruby/scripts/trade_logger.rb +0 -131
- data/skills/dhanhq-ruby/scripts/validate_order.rb +0 -169
- data/watchlist.csv +0 -3
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Models
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
##
|
|
7
|
+
# Margin requirement for a prospective Global Stocks order.
|
|
8
|
+
#
|
|
9
|
+
# @example Check affordability before placing
|
|
10
|
+
# margin = DhanHQ::Models::GlobalStocks::Margin.calculate(
|
|
11
|
+
# security_id: "AAPL", transaction_type: "BUY", price: 180.0, quantity: 10
|
|
12
|
+
# )
|
|
13
|
+
# puts "Need $#{margin.total_margin}, have $#{margin.available_bal}"
|
|
14
|
+
# raise "Insufficient funds" unless margin.sufficient?
|
|
15
|
+
#
|
|
16
|
+
class Margin < BaseModel
|
|
17
|
+
HTTP_PATH = "/v2/globalstocks/margincalculator"
|
|
18
|
+
|
|
19
|
+
attributes :insufficient_bal, :brokerage, :leverage, :total_margin, :available_bal
|
|
20
|
+
|
|
21
|
+
# Returns a concise prompt-friendly summary of the margin requirement.
|
|
22
|
+
def to_prompt
|
|
23
|
+
parts = []
|
|
24
|
+
parts << "total_margin=$#{total_margin}" if total_margin
|
|
25
|
+
parts << "available=$#{available_bal}" if available_bal
|
|
26
|
+
parts << "brokerage=$#{brokerage}" if brokerage
|
|
27
|
+
parts << "leverage=#{leverage}" if leverage
|
|
28
|
+
parts << "shortfall=$#{insufficient_bal}" if insufficient_bal.to_f.positive?
|
|
29
|
+
parts.join(", ")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] True when the account can fund the order.
|
|
33
|
+
def sufficient?
|
|
34
|
+
!insufficient_bal.to_f.positive?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @return [DhanHQ::Resources::GlobalStocks::MarginCalculator]
|
|
39
|
+
def resource
|
|
40
|
+
@resource ||= DhanHQ::Resources::GlobalStocks::MarginCalculator.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Calculates the margin required for a Global Stocks order.
|
|
44
|
+
#
|
|
45
|
+
# @param params [Hash] `:security_id`, `:transaction_type`, `:price`, `:quantity`
|
|
46
|
+
# @return [Margin]
|
|
47
|
+
def calculate(params)
|
|
48
|
+
new(resource.calculate(params), skip_validation: true)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Models
|
|
5
|
+
module GlobalStocks
|
|
6
|
+
##
|
|
7
|
+
# Current US market session status.
|
|
8
|
+
#
|
|
9
|
+
# @example Gate a strategy on the US session
|
|
10
|
+
# status = DhanHQ::Models::GlobalStocks::MarketStatus.fetch
|
|
11
|
+
# return unless status.open?
|
|
12
|
+
#
|
|
13
|
+
class MarketStatus < BaseModel
|
|
14
|
+
HTTP_PATH = "/v2/globalstocks/marketstatus"
|
|
15
|
+
|
|
16
|
+
attributes :status, :market_open_time, :market_close_time, :holiday_flag
|
|
17
|
+
|
|
18
|
+
# Returns a concise prompt-friendly summary of the session.
|
|
19
|
+
def to_prompt
|
|
20
|
+
parts = ["US market #{status}"]
|
|
21
|
+
parts << "open=#{market_open_time}" if market_open_time
|
|
22
|
+
parts << "close=#{market_close_time}" if market_close_time
|
|
23
|
+
parts << "holiday" if holiday?
|
|
24
|
+
parts.join(", ")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] True when the US market is currently open.
|
|
28
|
+
def open?
|
|
29
|
+
status.to_s.casecmp(DhanHQ::Constants::GlobalStocks::MarketStatus::OPEN).zero?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] True when the US market is currently closed.
|
|
33
|
+
def closed?
|
|
34
|
+
!open?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] True when today is a US market holiday.
|
|
38
|
+
def holiday?
|
|
39
|
+
holiday_flag == true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# @return [DhanHQ::Resources::GlobalStocks::MarketStatus]
|
|
44
|
+
def resource
|
|
45
|
+
@resource ||= DhanHQ::Resources::GlobalStocks::MarketStatus.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fetches the current US market status.
|
|
49
|
+
#
|
|
50
|
+
# @return [MarketStatus]
|
|
51
|
+
def fetch
|
|
52
|
+
new(resource.fetch, skip_validation: true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] True when the US market is currently open.
|
|
56
|
+
def open?
|
|
57
|
+
fetch.open?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Models
|
|
5
|
+
# Typed models for the Global Stocks (US equities) APIs.
|
|
6
|
+
#
|
|
7
|
+
# Global Stocks are a separate book from domestic NSE/BSE trading: separate
|
|
8
|
+
# order book, holdings, trades and a USD fund limit. They are namespaced so
|
|
9
|
+
# `Models::GlobalStocks::Holding` can never be confused with the domestic
|
|
10
|
+
# {DhanHQ::Models::Holding}.
|
|
11
|
+
module GlobalStocks
|
|
12
|
+
##
|
|
13
|
+
# A single Global Stocks (US equity) order.
|
|
14
|
+
#
|
|
15
|
+
# @example Place a limit order for one share of AAPL
|
|
16
|
+
# order = DhanHQ::Models::GlobalStocks::Order.place(
|
|
17
|
+
# transaction_type: "BUY",
|
|
18
|
+
# order_type: "LIMIT",
|
|
19
|
+
# security_id: "AAPL",
|
|
20
|
+
# quantity: 1,
|
|
21
|
+
# price: 180.0
|
|
22
|
+
# )
|
|
23
|
+
# order.order_id # => "112111182198"
|
|
24
|
+
#
|
|
25
|
+
# @example Place a notional ($ value) order
|
|
26
|
+
# DhanHQ::Models::GlobalStocks::Order.place(
|
|
27
|
+
# transaction_type: "BUY",
|
|
28
|
+
# order_type: "AMOUNT",
|
|
29
|
+
# security_id: "AAPL",
|
|
30
|
+
# amount: 500.0
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Fetch the order book
|
|
34
|
+
# DhanHQ::Models::GlobalStocks::Order.all.each do |o|
|
|
35
|
+
# puts "#{o.trading_symbol} #{o.order_status} #{o.traded_qty}/#{o.quantity}"
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
class Order < BaseModel
|
|
39
|
+
HTTP_PATH = "/v2/globalstocks/orders"
|
|
40
|
+
|
|
41
|
+
attributes :dhan_client_id, :order_id, :exchange_order_id, :correlation_id,
|
|
42
|
+
:transaction_type, :exchange_segment, :product_type, :order_type,
|
|
43
|
+
:validity, :trading_symbol, :display_name, :security_id,
|
|
44
|
+
:quantity, :remaining_quantity, :traded_qty, :price, :trigger_price,
|
|
45
|
+
:avg_traded_price, :order_status, :create_time, :exchange_time,
|
|
46
|
+
:update_time, :oms_error_code, :oms_error_description,
|
|
47
|
+
:after_market_order, :amount, :lot_size, :fractional_flag,
|
|
48
|
+
:leg_name, :child_orders
|
|
49
|
+
|
|
50
|
+
# Returns a concise prompt-friendly summary of the order.
|
|
51
|
+
def to_prompt
|
|
52
|
+
parts = ["#{transaction_type} #{quantity || amount}x #{trading_symbol || security_id}"]
|
|
53
|
+
parts << "type=#{order_type}"
|
|
54
|
+
parts << "price=$#{price}" if price&.positive?
|
|
55
|
+
parts << "status=#{order_status}" if order_status
|
|
56
|
+
parts << "traded=#{traded_qty}" if traded_qty&.positive?
|
|
57
|
+
parts << "avg=$#{avg_traded_price}" if avg_traded_price&.positive?
|
|
58
|
+
parts << "error=#{oms_error_description}" if oms_error_description
|
|
59
|
+
parts.join(", ")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] True when the order can still be modified or cancelled.
|
|
63
|
+
def pending?
|
|
64
|
+
[DhanHQ::Constants::OrderStatus::PENDING,
|
|
65
|
+
DhanHQ::Constants::OrderStatus::TRANSIT,
|
|
66
|
+
DhanHQ::Constants::OrderStatus::PART_TRADED].include?(order_status)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] True when the order is fully executed.
|
|
70
|
+
def traded?
|
|
71
|
+
order_status == DhanHQ::Constants::OrderStatus::TRADED
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] True when the order reached a terminal state.
|
|
75
|
+
def closed?
|
|
76
|
+
[DhanHQ::Constants::OrderStatus::TRADED,
|
|
77
|
+
DhanHQ::Constants::OrderStatus::CANCELLED,
|
|
78
|
+
DhanHQ::Constants::OrderStatus::REJECTED,
|
|
79
|
+
DhanHQ::Constants::OrderStatus::EXPIRED].include?(order_status)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] True for a notional (dollar-value) order.
|
|
83
|
+
def notional?
|
|
84
|
+
order_type == DhanHQ::Constants::GlobalStocks::OrderType::AMOUNT
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Boolean] True when the order is for a fractional share quantity.
|
|
88
|
+
def fractional?
|
|
89
|
+
return true if fractional_flag
|
|
90
|
+
|
|
91
|
+
quantity.is_a?(Float) && (quantity % 1 != 0)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Child legs of a Global Stocks super order, as {Order} instances.
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<Order>]
|
|
97
|
+
def child_legs
|
|
98
|
+
Array(child_orders).map { |leg| self.class.new(leg, skip_validation: true) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Modifies this order in place and returns the refreshed record.
|
|
102
|
+
#
|
|
103
|
+
# @param params [Hash] Fields to change, in snake_case.
|
|
104
|
+
# @return [Order, DhanHQ::ErrorObject]
|
|
105
|
+
def modify(params = {})
|
|
106
|
+
payload = { transaction_type: transaction_type, order_type: order_type, security_id: security_id }
|
|
107
|
+
.merge(params)
|
|
108
|
+
response = self.class.resource.update(order_id, payload)
|
|
109
|
+
return DhanHQ::ErrorObject.new(response) unless self.class.send(:successful?, response)
|
|
110
|
+
|
|
111
|
+
self.class.find(order_id)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Cancels this order.
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean] True when the API confirms cancellation.
|
|
117
|
+
def cancel
|
|
118
|
+
response = self.class.resource.cancel(order_id)
|
|
119
|
+
status = response.is_a?(Hash) ? (response[:orderStatus] || response[:order_status]) : nil
|
|
120
|
+
cancelled = status.to_s == DhanHQ::Constants::OrderStatus::CANCELLED
|
|
121
|
+
DhanHQ.logger&.error("[#{self.class.name}] Cancel failed for order #{order_id}: #{response}") unless cancelled
|
|
122
|
+
cancelled
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class << self
|
|
126
|
+
# @return [DhanHQ::Resources::GlobalStocks::Orders]
|
|
127
|
+
def resource
|
|
128
|
+
@resource ||= DhanHQ::Resources::GlobalStocks::Orders.new
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Retrieves the current trading day's Global Stocks order book.
|
|
132
|
+
#
|
|
133
|
+
# @return [Array<Order>]
|
|
134
|
+
def all
|
|
135
|
+
response = resource.all
|
|
136
|
+
return [] unless response.is_a?(Array)
|
|
137
|
+
|
|
138
|
+
response.map { |order| new(order, skip_validation: true) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Finds a single Global Stocks order by its Dhan order id.
|
|
142
|
+
#
|
|
143
|
+
# @param order_id [String]
|
|
144
|
+
# @return [Order, nil]
|
|
145
|
+
def find(order_id)
|
|
146
|
+
response = resource.find(order_id)
|
|
147
|
+
payload = response.is_a?(Array) ? response.first : response
|
|
148
|
+
return nil unless payload.is_a?(Hash) && !payload.empty?
|
|
149
|
+
|
|
150
|
+
new(payload, skip_validation: true)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Places a new Global Stocks order.
|
|
154
|
+
#
|
|
155
|
+
# @param params [Hash] See {DhanHQ::Contracts::GlobalStocksPlaceOrderContract}.
|
|
156
|
+
# @return [Order, DhanHQ::ErrorObject] The placed order, refetched so the
|
|
157
|
+
# caller gets the full record rather than just `{ orderId:, orderStatus: }`.
|
|
158
|
+
def place(params)
|
|
159
|
+
DhanHQ.logger&.info("[#{name}] Placing global order: #{params}")
|
|
160
|
+
response = resource.create(params)
|
|
161
|
+
return DhanHQ::ErrorObject.new(response) unless successful?(response)
|
|
162
|
+
|
|
163
|
+
id = response[:orderId] || response[:order_id]
|
|
164
|
+
DhanHQ.logger&.info("[#{name}] Global order placed successfully: #{id}")
|
|
165
|
+
find(id) || new(response, skip_validation: true)
|
|
166
|
+
end
|
|
167
|
+
alias create place
|
|
168
|
+
|
|
169
|
+
# Cancels a Global Stocks order by id.
|
|
170
|
+
#
|
|
171
|
+
# @param order_id [String]
|
|
172
|
+
# @return [Boolean]
|
|
173
|
+
def cancel(order_id)
|
|
174
|
+
response = resource.cancel(order_id)
|
|
175
|
+
status = response.is_a?(Hash) ? (response[:orderStatus] || response[:order_status]) : nil
|
|
176
|
+
status.to_s == DhanHQ::Constants::OrderStatus::CANCELLED
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
# An order write succeeded when the API echoed an order id back.
|
|
182
|
+
def successful?(response)
|
|
183
|
+
response.is_a?(Hash) && !(response[:orderId] || response[:order_id]).nil?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|