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
data/lib/DhanHQ/configuration.rb
CHANGED
|
@@ -41,6 +41,47 @@ module DhanHQ
|
|
|
41
41
|
# Whether to use the sandbox environment.
|
|
42
42
|
attr_accessor :sandbox
|
|
43
43
|
|
|
44
|
+
# When true, state-changing requests (order placement, modification,
|
|
45
|
+
# cancellation, position exits, trading controls) are validated and logged but
|
|
46
|
+
# never sent to the API — the client answers with a simulated response instead.
|
|
47
|
+
#
|
|
48
|
+
# Read-only requests still hit the API, so market data, the option chain,
|
|
49
|
+
# positions and holdings remain live. That makes dry-run usable for a full
|
|
50
|
+
# strategy rehearsal against real prices.
|
|
51
|
+
#
|
|
52
|
+
# Set via +DHAN_DRY_RUN=true+ or in {DhanHQ.configure}.
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
attr_accessor :dry_run
|
|
55
|
+
|
|
56
|
+
# Whether to auto-retry non-idempotent writes (order placement, modification,
|
|
57
|
+
# cancellation) after a transient failure — a 429, a 5xx, or a network timeout.
|
|
58
|
+
#
|
|
59
|
+
# Defaults to +false+, because the DhanHQ API has no idempotency key: a POST
|
|
60
|
+
# /v2/orders that times out may well have reached the exchange, and retrying it
|
|
61
|
+
# can place a second, duplicate order. With this off, the transient error is
|
|
62
|
+
# raised to the caller, who can reconcile using the correlation id (see
|
|
63
|
+
# {#auto_correlation_id} and +DhanHQ::Models::Order.find_by_correlation_id+)
|
|
64
|
+
# before deciding to resubmit.
|
|
65
|
+
#
|
|
66
|
+
# Read requests are always retried regardless of this setting.
|
|
67
|
+
#
|
|
68
|
+
# Set via +DHAN_RETRY_WRITES=true+ or in {DhanHQ.configure}.
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
attr_accessor :retry_non_idempotent_writes
|
|
71
|
+
|
|
72
|
+
# Whether to generate a +correlationId+ for order placements that do not carry
|
|
73
|
+
# one. The correlation id is the only way to answer "did my order actually go
|
|
74
|
+
# through?" after a timeout, via GET /v2/orders/external/{correlation-id}.
|
|
75
|
+
#
|
|
76
|
+
# Defaults to +false+ because it changes the request body, which would break
|
|
77
|
+
# callers matching recorded fixtures on the exact payload. Turn it on to get
|
|
78
|
+
# timeout-reconcilable orders for free. Only fills the field when absent; an
|
|
79
|
+
# explicit correlation id is always preserved.
|
|
80
|
+
#
|
|
81
|
+
# Set via +DHAN_AUTO_CORRELATION_ID=true+ or in {DhanHQ.configure}.
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
attr_accessor :auto_correlation_id
|
|
84
|
+
|
|
44
85
|
# URL for the compact CSV format of instruments.
|
|
45
86
|
# @return [String] URL for compact CSV.
|
|
46
87
|
attr_accessor :compact_csv_url
|
|
@@ -124,6 +165,21 @@ module DhanHQ
|
|
|
124
165
|
@sandbox == true
|
|
125
166
|
end
|
|
126
167
|
|
|
168
|
+
# @return [Boolean] True when state-changing requests should be simulated.
|
|
169
|
+
def dry_run?
|
|
170
|
+
@dry_run == true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [Boolean] True when non-idempotent writes may be auto-retried.
|
|
174
|
+
def retry_non_idempotent_writes?
|
|
175
|
+
@retry_non_idempotent_writes == true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @return [Boolean] True when a correlation id should be generated for orders.
|
|
179
|
+
def auto_correlation_id?
|
|
180
|
+
@auto_correlation_id == true
|
|
181
|
+
end
|
|
182
|
+
|
|
127
183
|
# Initializes a new configuration instance with default values.
|
|
128
184
|
#
|
|
129
185
|
# @example
|
|
@@ -133,7 +189,10 @@ module DhanHQ
|
|
|
133
189
|
def initialize
|
|
134
190
|
@client_id = ENV.fetch("DHAN_CLIENT_ID", nil)
|
|
135
191
|
@access_token = ENV.fetch("DHAN_ACCESS_TOKEN", nil)
|
|
136
|
-
@sandbox =
|
|
192
|
+
@sandbox = env_flag("DHAN_SANDBOX", default: false)
|
|
193
|
+
@dry_run = env_flag("DHAN_DRY_RUN", default: false)
|
|
194
|
+
@retry_non_idempotent_writes = env_flag("DHAN_RETRY_WRITES", default: false)
|
|
195
|
+
@auto_correlation_id = env_flag("DHAN_AUTO_CORRELATION_ID", default: false)
|
|
137
196
|
@base_url = ENV.fetch("DHAN_BASE_URL", nil)
|
|
138
197
|
@ws_version = ENV.fetch("DHAN_WS_VERSION", 2).to_i
|
|
139
198
|
@ws_order_url = ENV.fetch("DHAN_WS_ORDER_URL", nil)
|
|
@@ -144,5 +203,15 @@ module DhanHQ
|
|
|
144
203
|
@partner_id = ENV.fetch("DHAN_PARTNER_ID", nil)
|
|
145
204
|
@partner_secret = ENV.fetch("DHAN_PARTNER_SECRET", nil)
|
|
146
205
|
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# Reads a boolean-ish environment variable, falling back to +default+ when unset.
|
|
210
|
+
def env_flag(name, default:)
|
|
211
|
+
raw = ENV.fetch(name, nil)
|
|
212
|
+
return default if raw.nil? || raw.to_s.strip.empty?
|
|
213
|
+
|
|
214
|
+
raw.to_s.casecmp("true").zero?
|
|
215
|
+
end
|
|
147
216
|
end
|
|
148
217
|
end
|
data/lib/DhanHQ/constants.rb
CHANGED
|
@@ -16,8 +16,14 @@ module DhanHQ
|
|
|
16
16
|
MCX_COMM = "MCX_COMM"
|
|
17
17
|
BSE_CURRENCY = "BSE_CURRENCY"
|
|
18
18
|
BSE_FNO = "BSE_FNO"
|
|
19
|
+
# US / international equities, traded through the Global Stocks APIs
|
|
20
|
+
# (`/v2/globalstocks/*`). Deliberately excluded from {ALL} so it can never
|
|
21
|
+
# satisfy the domestic order contracts.
|
|
22
|
+
INX_EQ = "INX_EQ"
|
|
19
23
|
|
|
20
24
|
ALL = [IDX_I, NSE_EQ, NSE_FNO, NSE_CURRENCY, NSE_COMM, BSE_EQ, MCX_COMM, BSE_CURRENCY, BSE_FNO].freeze
|
|
25
|
+
# Segments served by the Global Stocks APIs.
|
|
26
|
+
GLOBAL_ALL = [INX_EQ].freeze
|
|
21
27
|
# Segments allowed by POST /v2/margincalculator (single and multi).
|
|
22
28
|
MARGIN_CALC_ALL = [NSE_EQ, NSE_FNO, BSE_EQ, BSE_FNO, MCX_COMM].freeze
|
|
23
29
|
# Segments allowed by POST /v2/forever/orders (create).
|
|
@@ -218,6 +224,64 @@ module DhanHQ
|
|
|
218
224
|
].freeze
|
|
219
225
|
end
|
|
220
226
|
|
|
227
|
+
# Enumerations specific to the Global Stocks (US equities) APIs served under
|
|
228
|
+
# `/v2/globalstocks/*`. These orders carry no exchange segment, product type
|
|
229
|
+
# or validity — the domestic {ProductType}/{Validity} enums do not apply.
|
|
230
|
+
#
|
|
231
|
+
# @see https://dhanhq.co/docs/v2/ Global Stocks section
|
|
232
|
+
module GlobalStocks
|
|
233
|
+
# The only exchange segment currently supported by the Global Stocks feed.
|
|
234
|
+
EXCHANGE_SEGMENT = ExchangeSegment::INX_EQ
|
|
235
|
+
# Numeric exchange-segment code used in the binary WebSocket header.
|
|
236
|
+
EXCHANGE_SEGMENT_CODE = 14
|
|
237
|
+
# Maximum instruments accepted per Global Stocks feed subscription frame.
|
|
238
|
+
MAX_INSTRUMENTS_PER_REQUEST = 100
|
|
239
|
+
# Maximum concurrent subscriptions on a single Global Stocks connection.
|
|
240
|
+
MAX_SUBSCRIPTIONS_PER_CONNECTION = 5_000
|
|
241
|
+
# Maximum concurrent Global Stocks feed connections per client.
|
|
242
|
+
MAX_CONNECTIONS_PER_CLIENT = 5
|
|
243
|
+
|
|
244
|
+
# Order types accepted by POST /v2/globalstocks/orders. Adds +AMOUNT+
|
|
245
|
+
# (notional/dollar-value orders) on top of the domestic order types.
|
|
246
|
+
module OrderType
|
|
247
|
+
MARKET = "MARKET"
|
|
248
|
+
LIMIT = "LIMIT"
|
|
249
|
+
STOP_LOSS = "STOP_LOSS"
|
|
250
|
+
STOP_LOSS_MARKET = "STOP_LOSS_MARKET"
|
|
251
|
+
AMOUNT = "AMOUNT"
|
|
252
|
+
|
|
253
|
+
ALL = [MARKET, LIMIT, STOP_LOSS, STOP_LOSS_MARKET, AMOUNT].freeze
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Leg names accepted when modifying a Global Stocks super order.
|
|
257
|
+
module LegName
|
|
258
|
+
ENTRY_LEG = "ENTRY_LEG"
|
|
259
|
+
STOP_LOSS_LEG = "STOP_LOSS_LEG"
|
|
260
|
+
TARGET_LEG = "TARGET_LEG"
|
|
261
|
+
NA = "NA"
|
|
262
|
+
|
|
263
|
+
ALL = [ENTRY_LEG, STOP_LOSS_LEG, TARGET_LEG, NA].freeze
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Values returned by GET /v2/globalstocks/marketstatus.
|
|
267
|
+
module MarketStatus
|
|
268
|
+
OPEN = "open"
|
|
269
|
+
CLOSED = "closed"
|
|
270
|
+
|
|
271
|
+
ALL = [OPEN, CLOSED].freeze
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Message codes on the Global Stocks binary feed.
|
|
275
|
+
module MsgCode
|
|
276
|
+
TRADE = 1
|
|
277
|
+
PREV_CLOSE = 32
|
|
278
|
+
CIRCUIT_LIMIT = 33
|
|
279
|
+
FIFTY_TWO_WEEK = 36
|
|
280
|
+
|
|
281
|
+
ALL = [TRADE, PREV_CLOSE, CIRCUIT_LIMIT, FIFTY_TWO_WEEK].freeze
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
221
285
|
# Comparison types for conditional trigger alerts.
|
|
222
286
|
module ComparisonType
|
|
223
287
|
TECHNICAL_WITH_VALUE = "TECHNICAL_WITH_VALUE"
|
|
@@ -381,9 +445,10 @@ module DhanHQ
|
|
|
381
445
|
|
|
382
446
|
# Public rate limits published in DhanHQ API documentation.
|
|
383
447
|
module RateLimit
|
|
384
|
-
ORDER_API = { per_second: 10,
|
|
385
|
-
DATA_API = { per_second: 5, per_day:
|
|
448
|
+
ORDER_API = { per_second: 10, per_day: 100_000 }.freeze
|
|
449
|
+
DATA_API = { per_second: 5, per_day: 7_000 }.freeze
|
|
386
450
|
QUOTE_API = { per_second: 1 }.freeze
|
|
451
|
+
OPTION_CHAIN = { per_second: 1.0 / 3 }.freeze
|
|
387
452
|
NON_TRADING_API = { per_second: 20 }.freeze
|
|
388
453
|
ORDER_MODIFICATIONS_PER_ORDER = 25
|
|
389
454
|
end
|
|
@@ -398,6 +463,7 @@ module DhanHQ
|
|
|
398
463
|
WS_ORDER_UPDATE = "wss://api-order-update.dhan.co"
|
|
399
464
|
WS_DEPTH_20 = "wss://depth-api-feed.dhan.co/twentydepth"
|
|
400
465
|
WS_DEPTH_200 = "wss://full-depth-api.dhan.co/twohundreddepth"
|
|
466
|
+
WS_GLOBAL_STOCKS_FEED = "wss://global-stocks-api-feed.dhan.co"
|
|
401
467
|
INSTRUMENT_CSV_COMPACT = "https://images.dhan.co/api-data/api-scrip-master.csv"
|
|
402
468
|
INSTRUMENT_CSV_DETAILED = "https://images.dhan.co/api-data/api-scrip-master-detailed.csv"
|
|
403
469
|
DOCS = "https://dhanhq.co/docs/v2"
|
|
@@ -423,6 +489,9 @@ module DhanHQ
|
|
|
423
489
|
COMPARISON_TYPES = ComparisonType::ALL
|
|
424
490
|
OPERATORS = Operator::ALL
|
|
425
491
|
ALERT_CONDITION_SEGMENTS = ExchangeSegment::ALERT_CONDITION_ALL
|
|
492
|
+
GLOBAL_STOCKS_SEGMENTS = ExchangeSegment::GLOBAL_ALL
|
|
493
|
+
GLOBAL_STOCKS_ORDER_TYPES = GlobalStocks::OrderType::ALL
|
|
494
|
+
GLOBAL_STOCKS_LEG_NAMES = GlobalStocks::LegName::ALL
|
|
426
495
|
ALERT_TIMEFRAMES = %w[DATE ONE_MIN FIVE_MIN FIFTEEN_MIN DAY].freeze
|
|
427
496
|
|
|
428
497
|
# Exchange aliases used when building subscription payloads.
|
|
@@ -474,6 +543,7 @@ module DhanHQ
|
|
|
474
543
|
# Injection is done in the client layer when building the payload.
|
|
475
544
|
PAYLOAD_REQUIRES_DHAN_CLIENT_ID_PREFIXES = %w[
|
|
476
545
|
/alerts/orders
|
|
546
|
+
/v2/alerts
|
|
477
547
|
/v2/orders
|
|
478
548
|
/v2/orders/iceberg
|
|
479
549
|
/v2/orders/twap
|
|
@@ -484,6 +554,38 @@ module DhanHQ
|
|
|
484
554
|
/v2/margincalculator
|
|
485
555
|
/v2/killswitch
|
|
486
556
|
/v2/ip
|
|
557
|
+
/v2/globalstocks
|
|
558
|
+
].freeze
|
|
559
|
+
|
|
560
|
+
# Path prefixes whose non-GET requests change real account state — they place,
|
|
561
|
+
# modify or cancel orders, exit positions, or alter trading controls.
|
|
562
|
+
#
|
|
563
|
+
# Note that a POST is not automatically a mutation on this API: option chain,
|
|
564
|
+
# market feed, historical charts and the margin calculators are all read-only
|
|
565
|
+
# POSTs. Dry-run mode and non-idempotent retry protection consult this list so
|
|
566
|
+
# they act on real writes only and leave data fetches alone.
|
|
567
|
+
MUTATING_PATH_PREFIXES = %w[
|
|
568
|
+
/v2/orders
|
|
569
|
+
/v2/super/orders
|
|
570
|
+
/v2/forever
|
|
571
|
+
/v2/alerts
|
|
572
|
+
/v2/globalstocks/orders
|
|
573
|
+
/v2/positions
|
|
574
|
+
/v2/killswitch
|
|
575
|
+
/v2/pnlExit
|
|
576
|
+
/v2/edis
|
|
577
|
+
/v2/ip
|
|
578
|
+
].freeze
|
|
579
|
+
|
|
580
|
+
# Subset of {MUTATING_PATH_PREFIXES} that create orders. A retried POST to one
|
|
581
|
+
# of these can produce a duplicate order, and dry-run mode answers these with a
|
|
582
|
+
# simulated order id so caller code paths still complete.
|
|
583
|
+
ORDER_PLACEMENT_PATH_PREFIXES = %w[
|
|
584
|
+
/v2/orders
|
|
585
|
+
/v2/super/orders
|
|
586
|
+
/v2/forever
|
|
587
|
+
/v2/alerts
|
|
588
|
+
/v2/globalstocks/orders
|
|
487
589
|
].freeze
|
|
488
590
|
|
|
489
591
|
# Mapping of exchange and segment combinations to canonical exchange segment names.
|
|
@@ -71,8 +71,7 @@ module DhanHQ
|
|
|
71
71
|
from_date = Date.parse(values[:from_date])
|
|
72
72
|
to_date = Date.parse(values[:to_date])
|
|
73
73
|
|
|
74
|
-
key.failure("from_date must be before to_date") if from_date
|
|
75
|
-
key.failure("from_date must be a valid trading date (no weekend dates)") unless trading_day?(from_date)
|
|
74
|
+
key.failure("from_date must be before or equal to to_date") if from_date > to_date
|
|
76
75
|
|
|
77
76
|
# Check if date range is not too large (max 31 days; to_date is non-inclusive)
|
|
78
77
|
key.failure("date range cannot exceed 31 days") if (to_date - from_date).to_i > 31
|
|
@@ -101,13 +100,6 @@ module DhanHQ
|
|
|
101
100
|
false
|
|
102
101
|
end
|
|
103
102
|
end
|
|
104
|
-
|
|
105
|
-
def trading_day?(date)
|
|
106
|
-
return false unless date.is_a?(Date)
|
|
107
|
-
|
|
108
|
-
# Sunday = 0, Saturday = 6; trading days are Monday (1) through Friday (5)
|
|
109
|
-
(1..5).cover?(date.wday)
|
|
110
|
-
end
|
|
111
103
|
end
|
|
112
104
|
end
|
|
113
105
|
end
|
|
@@ -17,7 +17,7 @@ module DhanHQ
|
|
|
17
17
|
required(:quantity).filled(:integer, gt?: 0)
|
|
18
18
|
required(:price).filled(:float, gt?: 0)
|
|
19
19
|
required(:trigger_price).filled(:float)
|
|
20
|
-
optional(:correlation_id).maybe(:string, max_size?:
|
|
20
|
+
optional(:correlation_id).maybe(:string, max_size?: 25, format?: /\A[a-zA-Z0-9 _-]*\z/)
|
|
21
21
|
optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
|
|
22
22
|
optional(:price1).maybe(:float, gt?: 0)
|
|
23
23
|
optional(:trigger_price1).maybe(:float)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_contract"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validation contract shared by POST /v2/globalstocks/transEstimate
|
|
8
|
+
# (order charge estimate) and POST /v2/globalstocks/margincalculator.
|
|
9
|
+
#
|
|
10
|
+
# Both endpoints accept the same +InxEstimatorRequest+ body. The API documents
|
|
11
|
+
# +price+ and +quantity+ as strings on the wire; this contract validates them
|
|
12
|
+
# as numbers and the resource stringifies them before sending.
|
|
13
|
+
class GlobalStocksEstimatorContract < BaseContract
|
|
14
|
+
TRANSACTION_TYPES = DhanHQ::Constants::TransactionType::ALL
|
|
15
|
+
|
|
16
|
+
params do
|
|
17
|
+
required(:security_id).filled(:string, max_size?: 50)
|
|
18
|
+
required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
|
|
19
|
+
required(:price).filled(:float, gteq?: 0)
|
|
20
|
+
required(:quantity).filled(:float, gt?: 0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
rule(:price) do
|
|
24
|
+
key.failure("must be a finite number") if value.is_a?(Float) && (value.nan? || value.infinite?)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "global_stocks_order_contract"
|
|
4
|
+
|
|
5
|
+
module DhanHQ
|
|
6
|
+
module Contracts
|
|
7
|
+
# Validation contract for PUT /v2/globalstocks/orders/{order-id}.
|
|
8
|
+
class GlobalStocksModifyOrderContract < GlobalStocksOrderContract
|
|
9
|
+
params do
|
|
10
|
+
required(:order_id).filled(:string)
|
|
11
|
+
required(:transaction_type).filled(:string, included_in?: TRANSACTION_TYPES)
|
|
12
|
+
required(:order_type).filled(:string, included_in?: ORDER_TYPES)
|
|
13
|
+
required(:security_id).filled(:string, max_size?: 50)
|
|
14
|
+
|
|
15
|
+
optional(:quantity).maybe(:float, gt?: 0)
|
|
16
|
+
optional(:price).maybe(:float, gteq?: 0)
|
|
17
|
+
optional(:leg_name).maybe(:string, included_in?: LEG_NAMES)
|
|
18
|
+
optional(:dhan_client_id).maybe(:string)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rule(:order_type, :price) do
|
|
22
|
+
key(:price).failure("must be greater than 0 for LIMIT orders") if PRICED_ORDER_TYPES.include?(values[:order_type]) && !values[:price]&.positive?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-validation"
|
|
4
|
+
require_relative "base_contract"
|
|
5
|
+
|
|
6
|
+
module DhanHQ
|
|
7
|
+
module Contracts
|
|
8
|
+
# Base contract shared by the Global Stocks (US equities) order contracts.
|
|
9
|
+
#
|
|
10
|
+
# Global Stocks orders differ from domestic orders in three ways that make the
|
|
11
|
+
# domestic {OrderContract} unusable here:
|
|
12
|
+
#
|
|
13
|
+
# 1. There is no +exchange_segment+, +product_type+ or +validity+ field.
|
|
14
|
+
# 2. +quantity+ is a float — fractional shares are supported.
|
|
15
|
+
# 3. +order_type+ adds +AMOUNT+, a notional (dollar-value) order which carries
|
|
16
|
+
# an +amount+ instead of a +quantity+.
|
|
17
|
+
#
|
|
18
|
+
# @see https://dhanhq.co/docs/v2/ Global Stocks section
|
|
19
|
+
class GlobalStocksOrderContract < BaseContract
|
|
20
|
+
TRANSACTION_TYPES = DhanHQ::Constants::TransactionType::ALL
|
|
21
|
+
ORDER_TYPES = DhanHQ::Constants::GlobalStocks::OrderType::ALL
|
|
22
|
+
LEG_NAMES = DhanHQ::Constants::GlobalStocks::LegName::ALL
|
|
23
|
+
|
|
24
|
+
# Order types that require a limit price.
|
|
25
|
+
PRICED_ORDER_TYPES = [DhanHQ::Constants::GlobalStocks::OrderType::LIMIT].freeze
|
|
26
|
+
# Order types that require a trigger price.
|
|
27
|
+
TRIGGERED_ORDER_TYPES = [
|
|
28
|
+
DhanHQ::Constants::GlobalStocks::OrderType::STOP_LOSS,
|
|
29
|
+
DhanHQ::Constants::GlobalStocks::OrderType::STOP_LOSS_MARKET
|
|
30
|
+
].freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -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
|
|
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
|
|
@@ -21,7 +21,7 @@ module DhanHQ
|
|
|
21
21
|
required(:price).filled(:float, gt?: 0)
|
|
22
22
|
required(:iceberg_qty).filled(:integer, gt?: 0)
|
|
23
23
|
required(:disclosed_quantity).filled(:integer, gteq?: 0)
|
|
24
|
-
optional(:correlation_id).maybe(:string, max_size?:
|
|
24
|
+
optional(:correlation_id).maybe(:string, max_size?: 25, format?: /\A[a-zA-Z0-9 _-]*\z/)
|
|
25
25
|
optional(:trigger_price).maybe(:float, gteq?: 0)
|
|
26
26
|
optional(:after_market_order).maybe(:bool)
|
|
27
27
|
optional(:amo_time).maybe(:string, included_in?: AMO_TIMINGS)
|
|
@@ -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
|
|
@@ -17,7 +17,7 @@ module DhanHQ
|
|
|
17
17
|
required(:quantity).filled(:integer, gt?: 0)
|
|
18
18
|
|
|
19
19
|
# Optional fields
|
|
20
|
-
optional(:correlation_id).maybe(:string, max_size?:
|
|
20
|
+
optional(:correlation_id).maybe(:string, max_size?: 25, format?: /\A[a-zA-Z0-9 _-]*\z/)
|
|
21
21
|
optional(:disclosed_quantity).maybe(:integer, gteq?: 0)
|
|
22
22
|
optional(:price).maybe(:float, gt?: 0)
|
|
23
23
|
optional(:trigger_price).maybe(:float, gt?: 0)
|
|
@@ -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)
|
|
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
|
|
@@ -22,7 +22,7 @@ module DhanHQ
|
|
|
22
22
|
required(:slice_interval).filled(:integer, gt?: 0)
|
|
23
23
|
required(:start_time).filled(:string, format?: /\A([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)\z/)
|
|
24
24
|
required(:end_time).filled(:string, format?: /\A([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)\z/)
|
|
25
|
-
optional(:correlation_id).maybe(:string, max_size?:
|
|
25
|
+
optional(:correlation_id).maybe(:string, max_size?: 25, format?: /\A[a-zA-Z0-9 _-]*\z/)
|
|
26
26
|
optional(:trigger_price).maybe(:float, gteq?: 0)
|
|
27
27
|
optional(:after_market_order).maybe(:bool)
|
|
28
28
|
optional(:amo_time).maybe(:string, included_in?: AMO_TIMINGS)
|