DhanHQ 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +73 -0
  4. data/README.md +241 -3
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +5 -4
  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/lib/DhanHQ/agent/key_coercion.rb +36 -0
  16. data/lib/DhanHQ/agent/tool.rb +37 -0
  17. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  18. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  19. data/lib/DhanHQ/agent/tool_registry.rb +17 -261
  20. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  21. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  22. data/lib/DhanHQ/client.rb +105 -30
  23. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +101 -0
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  28. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  29. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  30. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  31. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  32. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  33. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  34. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  35. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  36. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  37. data/lib/DhanHQ/models/alert_order.rb +6 -3
  38. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  39. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  40. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  41. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  42. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  43. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  44. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  45. data/lib/DhanHQ/models/margin.rb +5 -1
  46. data/lib/DhanHQ/models/multi_order.rb +130 -0
  47. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  48. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  49. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  50. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  51. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  52. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  53. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  54. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  55. data/lib/DhanHQ/version.rb +1 -1
  56. data/lib/DhanHQ/write_paths.rb +57 -0
  57. data/lib/DhanHQ/ws/client.rb +117 -2
  58. data/lib/DhanHQ/ws/connection.rb +40 -11
  59. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  60. data/lib/dhan_hq.rb +4 -0
  61. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  62. data/skills/dhanhq-ruby/SKILL.md +2 -1
  63. data/skills/dhanhq-ruby/examples/order_management.rb +7 -0
  64. data/skills/dhanhq-ruby/references/common-workflows.md +8 -8
  65. data/skills/dhanhq-ruby/references/instruments.md +7 -1
  66. data/skills/dhanhq-ruby/references/orders.md +2 -0
  67. metadata +30 -10
  68. data/.rspec +0 -3
  69. data/.rubocop.yml +0 -50
  70. data/.rubocop_todo.yml +0 -217
  71. data/Rakefile +0 -14
  72. data/TAGS +0 -10
  73. data/core +0 -0
  74. data/diagram.html +0 -184
  75. data/watchlist.csv +0 -3
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "global_stocks_order_contract"
4
+
5
+ module DhanHQ
6
+ module Contracts
7
+ # Validation contract for POST /v2/globalstocks/orders.
8
+ #
9
+ # @example
10
+ # DhanHQ::Contracts::GlobalStocksPlaceOrderContract.new.call(
11
+ # transaction_type: "BUY",
12
+ # order_type: "LIMIT",
13
+ # security_id: "AAPL",
14
+ # quantity: 1.0,
15
+ # price: 180.0
16
+ # )
17
+ class GlobalStocksPlaceOrderContract < GlobalStocksOrderContract
18
+ params do
19
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
20
+ required(:order_type).filled(:string, included_in?: ORDER_TYPES)
21
+ required(:security_id).filled(:string, max_size?: 50)
22
+
23
+ optional(:correlation_id).maybe(:string, max_size?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
24
+ optional(:quantity).maybe(:float, gt?: 0)
25
+ optional(:price).maybe(:float, gteq?: 0)
26
+ optional(:trigger_price).maybe(:float, gt?: 0)
27
+ optional(:stop_loss_price).maybe(:float, gt?: 0)
28
+ optional(:target_price).maybe(:float, gt?: 0)
29
+ optional(:amount).maybe(:float, gt?: 0)
30
+ optional(:after_market_order).maybe(:bool)
31
+ optional(:dhan_client_id).maybe(:string)
32
+ end
33
+
34
+ # AMOUNT orders are notional: they carry `amount` instead of `quantity`.
35
+ # Every other order type needs a quantity.
36
+ rule(:order_type, :quantity, :amount) do
37
+ if values[:order_type] == DhanHQ::Constants::GlobalStocks::OrderType::AMOUNT
38
+ key(:amount).failure("must be present for AMOUNT orders") unless values[:amount]
39
+ elsif !values[:quantity]
40
+ key(:quantity).failure("must be present unless order_type is AMOUNT")
41
+ end
42
+ end
43
+
44
+ rule(:order_type, :price) do
45
+ price = values[:price]
46
+
47
+ key(:price).failure("must be greater than 0 for LIMIT orders") if PRICED_ORDER_TYPES.include?(values[:order_type]) && !price&.positive?
48
+ key(:price).failure("must be a finite number") if price.is_a?(Float) && (price.nan? || price.infinite?)
49
+ end
50
+
51
+ rule(:order_type, :trigger_price) do
52
+ key(:trigger_price).failure("must be present for STOP_LOSS orders") if TRIGGERED_ORDER_TYPES.include?(values[:order_type]) && !values[:trigger_price]
53
+ end
54
+
55
+ # Mirrors the domestic super-order relationship checks: a BUY exits above
56
+ # entry and stops out below it, and vice versa for a SELL.
57
+ rule(:transaction_type, :price, :target_price, :stop_loss_price) do
58
+ price = values[:price]
59
+ next unless price&.positive?
60
+
61
+ target = values[:target_price]
62
+ stop = values[:stop_loss_price]
63
+
64
+ case values[:transaction_type]
65
+ when DhanHQ::Constants::TransactionType::BUY
66
+ key(:target_price).failure("must be greater than price for BUY orders") if target && target <= price
67
+ key(:stop_loss_price).failure("must be less than price for BUY orders") if stop && stop >= price
68
+ when DhanHQ::Constants::TransactionType::SELL
69
+ key(:target_price).failure("must be less than price for SELL orders") if target && target >= price
70
+ key(:stop_loss_price).failure("must be greater than price for SELL orders") if stop && stop <= price
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -18,36 +18,16 @@ module DhanHQ
18
18
  optional(:oi).maybe(:bool)
19
19
  end
20
20
 
21
- rule(:from_date) do
22
- next unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
23
-
24
- # Only validate weekend for pure date strings (YYYY-MM-DD)
25
- if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
26
- d = Date.parse(value)
27
- key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(d)
28
- end
29
- rescue Date::Error
30
- key.failure("invalid date format")
31
- end
32
-
33
21
  rule(:from_date, :to_date) do
34
22
  next unless values[:from_date].match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/) &&
35
23
  values[:to_date].match?(/\A\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?\z/)
36
24
 
37
25
  from_date = DateTime.parse(values[:from_date])
38
26
  to_date = DateTime.parse(values[:to_date])
39
- key.failure("from_date must be before to_date") if from_date >= to_date
27
+ key.failure("from_date must be before or equal to to_date") if from_date > to_date
40
28
  rescue Date::Error
41
29
  key.failure("invalid date format")
42
30
  end
43
-
44
- private
45
-
46
- def trading_day?(date)
47
- return false unless date.is_a?(Date)
48
-
49
- (1..5).cover?(date.wday)
50
- end
51
31
  end
52
32
  end
53
33
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-validation"
4
+ require_relative "base_contract"
5
+
6
+ module DhanHQ
7
+ module Contracts
8
+ # Validation contract for POST /v2/alerts/multi/orders (basket orders).
9
+ #
10
+ # A multi order places up to {MAX_ORDERS} unconditional orders in a single
11
+ # request. Each leg carries a +sequence+ that identifies it in the response.
12
+ class MultiOrderContract < BaseContract
13
+ # Maximum legs the API accepts in one basket.
14
+ MAX_ORDERS = 15
15
+
16
+ TRANSACTION_TYPES = DhanHQ::Constants::TransactionType::ALL
17
+ EXCHANGE_SEGMENTS = DhanHQ::Constants::ExchangeSegment::ALL
18
+ PRODUCT_TYPES = DhanHQ::Constants::ProductType::MARGIN_CALC_ALL
19
+ ORDER_TYPES = DhanHQ::Constants::OrderType::ALL
20
+ VALIDITY_TYPES = DhanHQ::Constants::Validity::ALL
21
+ AMO_TIMES = DhanHQ::Constants::AmoTime::ALL
22
+
23
+ params do
24
+ optional(:dhan_client_id).maybe(:string)
25
+
26
+ required(:orders).array(:hash) do
27
+ required(:sequence).filled(:string)
28
+ required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
29
+ required(:exchange_segment).filled(:string, included_in?: EXCHANGE_SEGMENTS)
30
+
31
+ optional(:product_type).maybe(:string, included_in?: PRODUCT_TYPES)
32
+ optional(:order_type).maybe(:string, included_in?: ORDER_TYPES)
33
+ optional(:validity).maybe(:string, included_in?: VALIDITY_TYPES)
34
+ optional(:security_id).maybe(:string)
35
+ optional(:quantity).maybe(:integer, gt?: 0)
36
+ optional(:price).maybe(:float, gt?: 0)
37
+ optional(:trigger_price).maybe(:float, gt?: 0)
38
+ optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
39
+ optional(:correlation_id).maybe(:string, max_size?: 30)
40
+ optional(:after_market_order).maybe(:bool)
41
+ optional(:amo_time).maybe(:string, included_in?: AMO_TIMES)
42
+ end
43
+ end
44
+
45
+ rule(:orders) do
46
+ key.failure("must contain at least one order") if value.empty?
47
+ key.failure("cannot contain more than #{MAX_ORDERS} orders") if value.size > MAX_ORDERS
48
+
49
+ sequences = value.map { |order| order[:sequence] }
50
+ key.failure("sequence values must be unique") if sequences.uniq.size != sequences.size
51
+ end
52
+
53
+ # Order types that require a trigger price on each leg.
54
+ TRIGGERED_ORDER_TYPES = [
55
+ DhanHQ::Constants::OrderType::STOP_LOSS,
56
+ DhanHQ::Constants::OrderType::STOP_LOSS_MARKET
57
+ ].freeze
58
+
59
+ # Per-leg price/trigger consistency, mirroring the single-order contract.
60
+ rule(:orders) do
61
+ value.each_with_index do |order, index|
62
+ type = order[:order_type]
63
+
64
+ key([:orders, index, :price]).failure("must be present for LIMIT orders") if type == DhanHQ::Constants::OrderType::LIMIT && !order[:price]
65
+ key([:orders, index, :price]).failure("must not be provided for MARKET orders") if type == DhanHQ::Constants::OrderType::MARKET && order[:price]
66
+
67
+ next unless TRIGGERED_ORDER_TYPES.include?(type) && !order[:trigger_price]
68
+
69
+ key([:orders, index, :trigger_price]).failure("must be present for STOP_LOSS orders")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -14,9 +14,6 @@ module DhanHQ
14
14
 
15
15
  rule(:from_date) do
16
16
  key.failure("must be in YYYY-MM-DD format (e.g., 2024-01-15)") unless valid_date_format?(value)
17
- next unless valid_date_format?(value)
18
-
19
- key.failure("must be a valid trading date (no weekend dates)") unless trading_day?(Date.parse(value))
20
17
  end
21
18
 
22
19
  rule(:to_date) do
@@ -28,7 +25,7 @@ module DhanHQ
28
25
  to = values[:to_date]
29
26
  next unless valid_date_format?(from) && valid_date_format?(to)
30
27
 
31
- key.failure("from_date must be before to_date") if Date.parse(from) >= Date.parse(to)
28
+ key.failure("from_date must be before or equal to to_date") if Date.parse(from) > Date.parse(to)
32
29
  rescue Date::Error
33
30
  key.failure("invalid date format")
34
31
  end
@@ -43,10 +40,6 @@ module DhanHQ
43
40
  rescue Date::Error
44
41
  false
45
42
  end
46
-
47
- def trading_day?(date)
48
- date.is_a?(Date) && (1..5).cover?(date.wday)
49
- end
50
43
  end
51
44
  end
52
45
  end
@@ -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
@@ -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
- response = resource.create(camelize_keys(normalized))
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, camelize_keys(payload))
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)
@@ -93,7 +96,7 @@ module DhanHQ
93
96
  handle_api_response(response, success_key: new_record? ? "alertId" : nil)
94
97
  end
95
98
 
96
- def destroy # rubocop:disable Naming/PredicateMethod
99
+ def destroy
97
100
  return false if new_record?
98
101
 
99
102
  response = self.class.resource.delete(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