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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +47 -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/client.rb +58 -2
  22. data/lib/DhanHQ/concerns/order_audit.rb +35 -4
  23. data/lib/DhanHQ/configuration.rb +70 -1
  24. data/lib/DhanHQ/constants.rb +101 -0
  25. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  26. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  27. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  28. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  29. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  30. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  31. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  32. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  33. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  34. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  35. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  36. data/lib/DhanHQ/models/alert_order.rb +5 -2
  37. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  38. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  39. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  40. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  41. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  42. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  43. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  44. data/lib/DhanHQ/models/margin.rb +5 -1
  45. data/lib/DhanHQ/models/multi_order.rb +130 -0
  46. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  47. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  48. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  49. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  50. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  51. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  52. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  53. data/lib/DhanHQ/version.rb +1 -1
  54. data/lib/DhanHQ/write_paths.rb +57 -0
  55. data/lib/DhanHQ/ws/client.rb +117 -2
  56. data/lib/DhanHQ/ws/connection.rb +40 -11
  57. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  58. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  59. metadata +30 -40
  60. data/.rspec +0 -3
  61. data/.rubocop.yml +0 -50
  62. data/.rubocop_todo.yml +0 -217
  63. data/AGENTS.md +0 -23
  64. data/CODE_OF_CONDUCT.md +0 -132
  65. data/Rakefile +0 -14
  66. data/TAGS +0 -10
  67. data/core +0 -0
  68. data/diagram.html +0 -184
  69. data/skills/dhanhq-ruby/SKILL.md +0 -207
  70. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +0 -54
  71. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +0 -65
  72. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +0 -89
  73. data/skills/dhanhq-ruby/examples/iron_condor.rb +0 -137
  74. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +0 -43
  75. data/skills/dhanhq-ruby/examples/margin_check.rb +0 -42
  76. data/skills/dhanhq-ruby/examples/order_management.rb +0 -105
  77. data/skills/dhanhq-ruby/examples/place_equity_order.rb +0 -36
  78. data/skills/dhanhq-ruby/examples/place_fno_order.rb +0 -76
  79. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +0 -74
  80. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +0 -57
  81. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +0 -65
  82. data/skills/dhanhq-ruby/references/common-workflows.md +0 -76
  83. data/skills/dhanhq-ruby/references/error-codes.md +0 -50
  84. data/skills/dhanhq-ruby/references/funds.md +0 -67
  85. data/skills/dhanhq-ruby/references/instruments.md +0 -85
  86. data/skills/dhanhq-ruby/references/live-feed.md +0 -83
  87. data/skills/dhanhq-ruby/references/market-data.md +0 -119
  88. data/skills/dhanhq-ruby/references/option-chain.md +0 -71
  89. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +0 -76
  90. data/skills/dhanhq-ruby/references/orders.md +0 -201
  91. data/skills/dhanhq-ruby/references/portfolio.md +0 -93
  92. data/skills/dhanhq-ruby/references/scanx-data.md +0 -62
  93. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +0 -323
  94. data/skills/dhanhq-ruby/scripts/resolve_security.rb +0 -168
  95. data/skills/dhanhq-ruby/scripts/trade_logger.rb +0 -131
  96. data/skills/dhanhq-ruby/scripts/validate_order.rb +0 -169
  97. 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
- 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)
@@ -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