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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +47 -1
  3. data/CHANGELOG.md +98 -0
  4. data/README.md +392 -6
  5. data/docs/AUTHENTICATION.md +3 -5
  6. data/docs/CONFIGURATION.md +3 -2
  7. data/docs/CONSTANTS_REFERENCE.md +8 -6
  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/exe/dhanhq-mcp +7 -0
  16. data/lib/DhanHQ/agent/key_coercion.rb +36 -0
  17. data/lib/DhanHQ/agent/tool.rb +37 -0
  18. data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
  19. data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
  20. data/lib/DhanHQ/agent/tool_registry.rb +17 -212
  21. data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
  22. data/lib/DhanHQ/client.rb +58 -2
  23. data/lib/DhanHQ/concerns/order_audit.rb +74 -1
  24. data/lib/DhanHQ/configuration.rb +70 -1
  25. data/lib/DhanHQ/constants.rb +104 -2
  26. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
  27. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  28. data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
  29. data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
  30. data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
  31. data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
  32. data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
  33. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
  34. data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
  35. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  36. data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
  37. data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
  38. data/lib/DhanHQ/dry_run/ledger.rb +71 -0
  39. data/lib/DhanHQ/dry_run/simulator.rb +140 -0
  40. data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
  41. data/lib/DhanHQ/mcp/server.rb +172 -9
  42. data/lib/DhanHQ/models/alert_order.rb +5 -2
  43. data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
  44. data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
  45. data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
  46. data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
  47. data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
  48. data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
  49. data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
  50. data/lib/DhanHQ/models/instrument.rb +44 -14
  51. data/lib/DhanHQ/models/margin.rb +5 -1
  52. data/lib/DhanHQ/models/multi_order.rb +130 -0
  53. data/lib/DhanHQ/rate_limiter.rb +5 -3
  54. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  55. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  56. data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
  57. data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
  58. data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
  59. data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
  60. data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
  61. data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
  62. data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
  63. data/lib/DhanHQ/resources/multi_orders.rb +58 -0
  64. data/lib/DhanHQ/resources/orders.rb +2 -0
  65. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  66. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  67. data/lib/DhanHQ/resources/twap_orders.rb +1 -0
  68. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  69. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  70. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  71. data/lib/DhanHQ/risk/pipeline.rb +8 -1
  72. data/lib/DhanHQ/skills/base.rb +54 -3
  73. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  74. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  75. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
  76. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  77. data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
  78. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  79. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  80. data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
  81. data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
  82. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  83. data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
  84. data/lib/DhanHQ/version.rb +1 -1
  85. data/lib/DhanHQ/write_paths.rb +57 -0
  86. data/lib/DhanHQ/ws/client.rb +117 -2
  87. data/lib/DhanHQ/ws/connection.rb +40 -11
  88. data/lib/DhanHQ/ws/sub_state.rb +14 -0
  89. data/lib/dhan_hq.rb +47 -0
  90. data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
  91. metadata +41 -15
  92. data/.rspec +0 -3
  93. data/.rubocop.yml +0 -48
  94. data/.rubocop_todo.yml +0 -217
  95. data/AGENTS.md +0 -23
  96. data/CODE_OF_CONDUCT.md +0 -132
  97. data/Rakefile +0 -14
  98. data/TAGS +0 -10
  99. data/core +0 -0
  100. data/diagram.html +0 -184
  101. data/skills/dhanhq-ruby/SKILL.md +0 -74
  102. data/skills/dhanhq-ruby/references/market_data.md +0 -3
  103. data/skills/dhanhq-ruby/references/orders.md +0 -7
  104. data/watchlist.csv +0 -3
data/README.md CHANGED
@@ -117,7 +117,11 @@ For the full dependency flow and extension pattern, see [ARCHITECTURE.md](ARCHIT
117
117
  - **Instrument convenience methods** — `.ltp`, `.ohlc`, `.option_chain` directly on instruments
118
118
  - **Order audit logging** — every order attempt logs machine, IP, environment, and correlation ID as structured JSON
119
119
  - **Live trading guard** — prevents accidental order placement unless `ENV["LIVE_TRADING"]="true"`
120
- - **Full REST coverage** — Orders, Trades, Forever Orders, Super Orders, Positions, Holdings, Funds, HistoricalData, OptionChain, MarketFeed, EDIS, Kill Switch, P&L Exit, Alert Orders, Margin Calculator
120
+ - **Dry-run mode** — `config.dry_run = true` validates and logs every write while reads stay live, so a full strategy can be rehearsed against real prices
121
+ - **No duplicate orders on retry** — transient failures on order writes are surfaced, not blindly retried
122
+ - **Global Stocks (US equities)** — separate order book, holdings, trades, USD funds, market status, charge estimator and margin calculator
123
+ - **Basket orders** — up to 15 orders in a single request, with per-leg acceptance results
124
+ - **Full REST coverage** — Orders, Trades, Forever Orders, Super Orders, Multi (basket) Orders, Positions, Holdings, Funds, HistoricalData, OptionChain, MarketFeed, EDIS, Kill Switch, P&L Exit, Alert Orders, Margin Calculator, IP Setup, Global Stocks
121
125
  - **P&L Based Exit** — automatic position exit on profit/loss thresholds
122
126
  - **Postback parser** — parse Dhan webhook payloads with `Postback.parse` and status predicates
123
127
  - **EDIS model** — ORM-style T-PIN, form, and status inquiry for delivery instruction slips
@@ -127,10 +131,12 @@ For the full dependency flow and extension pattern, see [ARCHITECTURE.md](ARCHIT
127
131
  ## Reliability & Safety
128
132
 
129
133
  - retry-on-401 with token refresh
130
- - WebSocket auto-reconnect and backoff
134
+ - WebSocket auto-reconnect, backoff, and automatic re-subscription
131
135
  - 429 rate-limit protection
132
136
  - live trading guard via `LIVE_TRADING=true`
133
137
  - structured order audit logs
138
+ - dry-run mode via `DHAN_DRY_RUN=true`
139
+ - order writes are never silently retried, so a timeout cannot become two orders
134
140
 
135
141
  See [ARCHITECTURE.md](ARCHITECTURE.md), [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md), and [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for the deeper implementation details.
136
142
 
@@ -219,6 +225,95 @@ LIVE_TRADING=false # or simply omit
219
225
 
220
226
  Attempting to place an order without `LIVE_TRADING=true` raises `DhanHQ::LiveTradingDisabledError`.
221
227
 
228
+ ### Dry-Run Mode
229
+
230
+ `dry_run` suppresses every request that would change account state — order placement,
231
+ modification, cancellation, position exits, kill switch, P&L exit — while letting reads
232
+ through untouched. Market data, the option chain, positions and holdings stay live, so a
233
+ strategy can be rehearsed end-to-end against real prices without any risk of an order
234
+ reaching the exchange.
235
+
236
+ ```ruby
237
+ DhanHQ.configure do |config|
238
+ config.dry_run = true # or DHAN_DRY_RUN=true
239
+ end
240
+
241
+ order = DhanHQ::Models::Order.place(
242
+ transaction_type: DhanHQ::Constants::TransactionType::BUY,
243
+ exchange_segment: DhanHQ::Constants::ExchangeSegment::NSE_EQ,
244
+ product_type: DhanHQ::Constants::ProductType::INTRADAY,
245
+ order_type: DhanHQ::Constants::OrderType::LIMIT,
246
+ validity: DhanHQ::Constants::Validity::DAY,
247
+ security_id: "11536", quantity: 5, price: 1500.0
248
+ )
249
+ ```
250
+
251
+ Suppressed writes are logged at WARN level with the full payload, and order placements come
252
+ back with a simulated `orderId` so downstream code paths run to completion:
253
+
254
+ ```json
255
+ { "event": "DHAN_DRY_RUN", "method": "POST", "path": "/v2/orders", "payload": { "…": "…" } }
256
+ ```
257
+
258
+ Note that a POST is not automatically a write on this API: the option chain, market feed,
259
+ historical charts and the margin calculators are read-only POSTs, and dry-run leaves them
260
+ alone.
261
+
262
+ This works through the high-level models too, not just `Client#request`. Because
263
+ `Order.place` re-fetches after placing, the SDK records each simulated order and replays reads
264
+ for its `DRYRUN-…` id locally — so a rehearsal issues no request for a fake order, and the
265
+ model you get back carries the fields you submitted:
266
+
267
+ ```ruby
268
+ order.order_id # => "DRYRUN-9F7BEB55D0F9"
269
+ order.security_id # => "11536"
270
+ ```
271
+
272
+ `dry_run` complements `Agent::OrderPreview` — preview validates a single order and returns a
273
+ summary, while `dry_run` covers every write path in the SDK, including skills and the MCP
274
+ tools.
275
+
276
+ ### Order Retries and Duplicate Protection
277
+
278
+ The DhanHQ API has no idempotency key. A `POST /v2/orders` that times out may well have
279
+ reached the exchange, so retrying it can place a second, real order. By default this SDK
280
+ therefore **does not** retry non-idempotent writes — the transient error is raised to you:
281
+
282
+ ```ruby
283
+ begin
284
+ DhanHQ::Models::Order.place(...)
285
+ rescue DhanHQ::NetworkError
286
+ # The order may or may not have been accepted. Reconcile before resubmitting.
287
+ end
288
+ ```
289
+
290
+ Reads, and read-only POSTs like the option chain, are still retried with exponential backoff.
291
+
292
+ To reconcile after a timeout, place orders with a correlation id and look the order up by it:
293
+
294
+ ```ruby
295
+ DhanHQ.configure do |config|
296
+ config.auto_correlation_id = true # or DHAN_AUTO_CORRELATION_ID=true
297
+ end
298
+ ```
299
+
300
+ With this on, any order placement missing a `correlationId` gets one (`dhq-<hex>`), which you
301
+ can then resolve via `GET /v2/orders/external/{correlation-id}`:
302
+
303
+ ```ruby
304
+ DhanHQ::Resources::Orders.new.by_correlation("dhq-2f9c1a…")
305
+ ```
306
+
307
+ It is off by default because it changes the request body, which would break callers that match
308
+ recorded fixtures on the exact payload. An explicit `correlation_id` is always preserved.
309
+
310
+ If you would rather have the old auto-retry behaviour, opt back in — accepting the duplicate
311
+ risk:
312
+
313
+ ```ruby
314
+ DhanHQ.configure { |config| config.retry_non_idempotent_writes = true }
315
+ ```
316
+
222
317
  ### Order Audit Logging
223
318
 
224
319
  Every order attempt (place, modify, slice) automatically logs a structured JSON line at WARN level:
@@ -342,6 +437,45 @@ DhanHQ::WS::MarketDepth.connect(symbols: [
342
437
  end
343
438
  ```
344
439
 
440
+ ### Lifecycle Hooks and Health Checks
441
+
442
+ Subscriptions are restored automatically on reconnect — the server keeps no subscription state
443
+ across connections, so the client replays the desired set on every new session. Hook `:reconnect`
444
+ when you need to re-seed derived state (candle builders, caches) after a gap:
445
+
446
+ ```ruby
447
+ client = DhanHQ::WS.connect(mode: :ticker) { |tick| handle(tick) }
448
+
449
+ client.on(:open) { |info| logger.info "feed open, #{info[:resubscribed].size} restored" }
450
+ client.on(:reconnect) { |info| logger.warn "reconnect ##{info[:attempt]}"; candles.reset! }
451
+ client.on(:close) { |info| logger.warn "closed #{info[:code]} #{info[:reason]}" }
452
+ client.on(:error) { |message| logger.error message }
453
+ ```
454
+
455
+ A feed can be connected and still be dead — the socket stays open while the server stops
456
+ publishing. For long-running daemons, watch frame arrival rather than the socket alone:
457
+
458
+ ```ruby
459
+ client.connected? # socket state
460
+ client.healthy? # connected AND delivering frames (default: within 45s)
461
+ client.healthy?(stale_after: 20) # tighter threshold
462
+ client.last_message_at # Time of the most recent frame
463
+ client.seconds_since_last_message
464
+ client.reconnect_count
465
+ client.subscriptions # ["NSE_EQ:11536", …] — survives reconnects
466
+
467
+ client.health
468
+ # => { mode: :ticker, started: true, connected: true, healthy: true,
469
+ # connected_at: …, last_message_at: …, seconds_since_last_message: 0.4,
470
+ # reconnect_count: 2, subscription_count: 37 }
471
+ ```
472
+
473
+ `health` is shaped for a monitoring endpoint or a periodic log line.
474
+
475
+ > **Connection limit:** Dhan allows **5 concurrent WebSocket connections per client id**, with
476
+ > 5,000 instruments per connection and 100 instruments per subscribe frame. Running several
477
+ > strategies in separate processes counts against the same limit — a 6th connection is refused.
478
+
345
479
  ### Cleanup
346
480
 
347
481
  ```ruby
@@ -373,6 +507,110 @@ DhanHQ::Models::SuperOrder.create(
373
507
 
374
508
  ---
375
509
 
510
+ ## Basket Orders (Multi Order)
511
+
512
+ Place up to 15 unconditional orders in a single request. Each leg carries a `sequence` that
513
+ identifies it in the response, so partial acceptance is visible:
514
+
515
+ ```ruby
516
+ result = DhanHQ::Models::MultiOrder.place([
517
+ { sequence: "1", transaction_type: "BUY", exchange_segment: "NSE_EQ", product_type: "CNC",
518
+ order_type: "LIMIT", validity: "DAY", security_id: "11536", quantity: 1, price: 1500.0 },
519
+ { sequence: "2", transaction_type: "BUY", exchange_segment: "NSE_EQ", product_type: "CNC",
520
+ order_type: "MARKET", validity: "DAY", security_id: "1333", quantity: 2 }
521
+ ])
522
+
523
+ result.all_accepted? # => true
524
+ result.order_ids # => ["112111182198", "112111182199"]
525
+ result.rejected # legs the exchange refused
526
+ result.partially_accepted? # some in, some out
527
+ result.for_sequence("1").order_id # look a leg up by its sequence
528
+ ```
529
+
530
+ Every leg is validated before anything is sent, and each one gets its own audit log line.
531
+ Requires `LIVE_TRADING=true` like any other order path.
532
+
533
+ ---
534
+
535
+ ## Global Stocks (US Equities)
536
+
537
+ US stocks are a **separate book** from domestic NSE/BSE trading: their own order book,
538
+ holdings, trades, and a USD fund limit. They are namespaced under `GlobalStocks` so a USD
539
+ position can never be mistaken for an INR one.
540
+
541
+ ```ruby
542
+ # Is the US market open?
543
+ DhanHQ::Models::GlobalStocks::MarketStatus.fetch.open?
544
+
545
+ # USD balance and portfolio
546
+ DhanHQ::Models::GlobalStocks::Funds.fetch.available_cash
547
+ DhanHQ::Models::GlobalStocks::Holding.all.each do |h|
548
+ puts "#{h.trading_symbol}: #{h.quantity} @ $#{h.avg_cost_price} (#{h.gain_percentage}%)"
549
+ end
550
+ DhanHQ::Models::GlobalStocks::Holding.total_current_value
551
+ ```
552
+
553
+ ### Placing US orders
554
+
555
+ Global Stocks orders carry **no exchange segment, product type, or validity** — and quantity
556
+ is a float, because fractional shares are supported:
557
+
558
+ ```ruby
559
+ DhanHQ::Models::GlobalStocks::Order.place(
560
+ transaction_type: DhanHQ::Constants::TransactionType::BUY,
561
+ order_type: DhanHQ::Constants::GlobalStocks::OrderType::LIMIT,
562
+ security_id: "AAPL",
563
+ quantity: 0.5, # fractional shares
564
+ price: 180.0
565
+ )
566
+ ```
567
+
568
+ `AMOUNT` is a notional order type unique to this book — you name a dollar value instead of a
569
+ share count:
570
+
571
+ ```ruby
572
+ DhanHQ::Models::GlobalStocks::Order.place(
573
+ transaction_type: DhanHQ::Constants::TransactionType::BUY,
574
+ order_type: DhanHQ::Constants::GlobalStocks::OrderType::AMOUNT,
575
+ security_id: "AAPL",
576
+ amount: 500.0 # buy $500 worth
577
+ )
578
+ ```
579
+
580
+ ### Pre-trade checks
581
+
582
+ ```ruby
583
+ params = { security_id: "AAPL", transaction_type: "BUY", price: 180.0, quantity: 10 }
584
+
585
+ estimate = DhanHQ::Models::GlobalStocks::OrderEstimate.calculate(params)
586
+ estimate.total_charges # brokerage + exchange + GST + turnover + other
587
+
588
+ margin = DhanHQ::Models::GlobalStocks::Margin.calculate(params)
589
+ margin.sufficient? # false when the account is short
590
+ margin.total_margin
591
+ ```
592
+
593
+ ### Order and trade books
594
+
595
+ ```ruby
596
+ DhanHQ::Models::GlobalStocks::Order.all # today's US order book
597
+ DhanHQ::Models::GlobalStocks::Order.find(order_id)
598
+ DhanHQ::Models::GlobalStocks::Trade.all # today's US fills
599
+ DhanHQ::Models::GlobalStocks::Trade.find_by_security_id("AAPL")
600
+ ```
601
+
602
+ Writes go through the same `LIVE_TRADING` gate, audit logging, and dry-run handling as
603
+ domestic orders. The pre-trade `Risk::Pipeline` is deliberately **not** applied: its checks
604
+ resolve instruments from the Indian scrip master and encode NSE/BSE rules (lot sizes, ASM/GSM
605
+ surveillance, F&O product support, IST market hours), none of which apply to US equities. Use
606
+ `Margin#sufficient?` and `OrderEstimate` as the pre-trade gate here instead.
607
+
608
+ The Global Stocks live feed (`wss://global-stocks-api-feed.dhan.co`, segment `INX_EQ`, code
609
+ `14`) is documented in `DhanHQ::Constants::GlobalStocks`; its binary packet decoder is not yet
610
+ implemented.
611
+
612
+ ---
613
+
376
614
  ## Real-World Example: NIFTY Trend Monitor
377
615
 
378
616
  ```ruby
@@ -421,13 +659,18 @@ Need initializers, service objects, ActionCable wiring, and background workers?
421
659
 
422
660
  These scripts are designed around user goals rather than API surfaces:
423
661
 
424
- | Example | Use case |
425
- | ------- | -------- |
426
- | [examples/basic_trading_bot.rb](examples/basic_trading_bot.rb) | Pull historical data, evaluate a simple signal, and place a guarded order |
427
- | [examples/portfolio_monitor.rb](examples/portfolio_monitor.rb) | Snapshot funds, holdings, and positions for a monitoring script |
662
+ | Example | What it covers |
663
+ | ------- | --------------- |
664
+ | [examples/basic_trading_bot.rb](examples/basic_trading_bot.rb) | Trading bot scaffold with live-trading guard |
665
+ | [examples/comprehensive_websocket_examples.rb](examples/comprehensive_websocket_examples.rb) | WebSocket mode coverage and timeout handling |
428
666
  | [examples/options_watchlist.rb](examples/options_watchlist.rb) | Build a live options watchlist with index quotes and option-chain context |
429
667
  | [examples/market_feed_example.rb](examples/market_feed_example.rb) | Subscribe to major market indices over WebSocket |
668
+ | [examples/market_depth_example.rb](examples/market_depth_example.rb) | Market depth streaming example |
430
669
  | [examples/live_order_updates.rb](examples/live_order_updates.rb) | Track order lifecycle events in real time |
670
+ | [examples/order_update_example.rb](examples/order_update_example.rb) | Single-session order-update flow |
671
+ | [examples/portfolio_monitor.rb](examples/portfolio_monitor.rb) | Snapshot funds, holdings, and positions for a monitoring script |
672
+ | [examples/trading_fields_example.rb](examples/trading_fields_example.rb) | Dhan order-field mappings and constants example |
673
+ | [examples/instrument_finder_test.rb](examples/instrument_finder_test.rb) | Instrument search/resolution troubleshooting |
431
674
 
432
675
  For search-driven discovery and onboarding content, see:
433
676
 
@@ -471,6 +714,149 @@ For search-driven discovery and onboarding content, see:
471
714
 
472
715
  ---
473
716
 
717
+ ## MCP Server (AI Agent Integration)
718
+
719
+ DhanHQ includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI coding agents (Claude Code, Codex, OpenCode, Cursor) interact with your Dhan account directly from the editor or CLI.
720
+
721
+ ### Quick Start
722
+
723
+ ```bash
724
+ # 1. Configure credentials
725
+ export DHAN_CLIENT_ID="your_client_id"
726
+ export DHAN_ACCESS_TOKEN="your_access_token"
727
+
728
+ # 2. Start the server (stdio)
729
+ bundle exec dhanhq-mcp
730
+ ```
731
+
732
+ Claude Desktop config (`claude_desktop_config.json`):
733
+
734
+ ```json
735
+ {
736
+ "mcpServers": {
737
+ "dhanhq": {
738
+ "command": "bundle",
739
+ "args": ["exec", "dhanhq-mcp"],
740
+ "env": {
741
+ "DHAN_CLIENT_ID": "your_client_id",
742
+ "DHAN_ACCESS_TOKEN": "your_access_token"
743
+ }
744
+ }
745
+ }
746
+ }
747
+ ```
748
+
749
+ ### MCP Features
750
+
751
+ | Feature | Description |
752
+ | ------- | ----------- |
753
+ | **Tools** | 32 total: 12 domestic primitives (profile, funds, holdings, positions, order history, order preview/place/cancel, instruments, market feed) + 9 Global Stocks and basket tools (`dhan_global_*`, `dhan_multi_order`) + 11 skill-derived tools (`dhan_skill_*` — one per builtin strategy in [Skills System](#skills-system) below) |
754
+ | **Resources** | 6 URI-addressable data endpoints: `dhanhq://account/profile`, `dhanhq://account/funds`, `dhanhq://account/holdings`, `dhanhq://account/positions`, `dhanhq://account/orders`, `dhanhq://market/capabilities` |
755
+ | **Prompts** | 5 pre-built AI prompts: `portfolio_summary`, `market_analysis`, `risk_report`, `order_preview`, `suggest_strategy` |
756
+
757
+ ### Security & Policy
758
+
759
+ ```ruby
760
+ # Read-only mode (no order placement)
761
+ DhanHQ::Agent::Policy.read_only
762
+
763
+ # Scope-based policy from env (DHANHQ_AGENT_SCOPES)
764
+ DhanHQ::Agent::Policy.from_env
765
+ ```
766
+
767
+ The policy engine respects `DHANHQ_MCP_ENABLE_WRITES` and `LIVE_TRADING` env vars. Write operations are blocked by default — explicit opt-in required.
768
+
769
+ ## Skills System
770
+
771
+ Skills are reusable, composable trading strategies. DhanHQ ships with **11 builtin skills** and a registry for discovery and invocation.
772
+
773
+ ### Builtin Skills
774
+
775
+ | Skill | Type | Description |
776
+ | ----- | ---- | ----------- |
777
+ | `buy_atm_call` | Single-leg | Buy ATM call option |
778
+ | `square_off_all` | Action | Square off all open positions |
779
+ | `square_off_position` | Action | Square off a specific position |
780
+ | `iron_condor` | Multi-leg | Sell OTM put + buy further OTM put + sell OTM call + buy further OTM call |
781
+ | `strangle` | Multi-leg | Buy OTM put + buy OTM call |
782
+ | `covered_call` | Multi-leg | Buy equity + sell OTM call |
783
+ | `bull_put_spread` | Multi-leg | Sell OTM put + buy further OTM put |
784
+ | `bear_call_spread` | Multi-leg | Sell OTM call + buy further OTM call |
785
+ | `protective_put` | Multi-leg | Buy equity + buy OTM put |
786
+ | `straddle` | Multi-leg | Buy ATM call + buy ATM put |
787
+ | `market_data_summarizer` | Read-only | Summarize technicals and/or option chain for a symbol |
788
+
789
+ ### Using Skills
790
+
791
+ ```ruby
792
+ # Register all builtin skills
793
+ DhanHQ::Skills::Registry.load_builtins
794
+
795
+ # Find a skill by name
796
+ skill = DhanHQ::Skills::Registry.find("covered_call")
797
+
798
+ # Invoke — returns an intent hash (trade_type, legs, risk metadata)
799
+ result = skill.call(symbol: "RELIANCE", expiry: "2026-06-25")
800
+ # => { intent: { trade_type: "COVERED_CALL", legs: [...], total_premium: ..., break_even: ..., note: "..." } }
801
+ ```
802
+
803
+ Skills return intent hashes (not executed trades), keeping a human-in-the-loop safety pattern.
804
+
805
+ ## AI Integration
806
+
807
+ DhanHQ provides prompt helpers and risk reporting for LLM-powered trading agents.
808
+
809
+ ### Prompt Helpers
810
+
811
+ ```ruby
812
+ require 'dhan_hq/ai'
813
+
814
+ # Portfolio summary for AI consumption
815
+ DhanHQ::AI::PromptHelpers.portfolio_summary
816
+ # => "Portfolio Summary\n━━━━━━━━━━━━━\nFunds: ₹1,00,000.00\n..."
817
+
818
+ # Risk report
819
+ DhanHQ::AI::PromptHelpers.risk_report
820
+ # => "🔴 RISK ALERT: 394.6% drawdown..."
821
+ ```
822
+
823
+ ### Risk Pipeline
824
+
825
+ ```ruby
826
+ # Run pre-trade risk checks
827
+ DhanHQ::Risk::Pipeline.run!(
828
+ instrument: instrument,
829
+ args: { quantity: 25, price: 24_500 },
830
+ type: :fno,
831
+ now: Time.now
832
+ )
833
+ ```
834
+
835
+ Available checks:
836
+ - **TradingPermission** — blocks instruments where trading is disabled (`buy_sell_indicator != "A"`)
837
+ - **AsmGsm** — blocks ASM/GSM restricted instruments
838
+ - **ProductSupport** — validates bracket/cover order support for the instrument
839
+ - **OrderType** — restricts to `MARKET`/`LIMIT` order types
840
+ - **Quantity** — max 10 units / ₹1,00,000 notional (an agent-safety limit, not a general trading cap)
841
+ - **MarketHours** — verifies market is open (9:15 AM–3:30 PM IST)
842
+ - **PositionLimits** — max 20 concurrent open positions
843
+ - **Concentration** — max 25% of available balance in a single symbol
844
+ - **Options** (options only) — index-only, requires stop loss + target + risk-reward
845
+ - **MaxLoss** (daily) — daily loss limit (default ₹50,000)
846
+
847
+ Checks raise `DhanHQ::RiskViolation` with human-readable messages, safe for AI parsing. Wired into every order-placing path via `DhanHQ::Concerns::OrderAudit#run_risk_checks!` (Orders, SuperOrders, ForeverOrders, AlertOrders, TwapOrders, IcebergOrders, PnL Exit) and into the `dhan_place_order` MCP tool.
848
+
849
+ ### Known Limitations
850
+
851
+ This gem's core REST/WS client (orders, positions, funds, market data) is mature and already depended on in production by other repos in this workspace. The MCP server, Skills system, and Risk pipeline are newer and have now been verified live end to end — including the full write path — against real Dhan accounts, a real independent MCP client, and a real order-matching engine — see [CHANGELOG.md](CHANGELOG.md#known-limitations) for details:
852
+
853
+ - Read path (profile/funds/holdings/positions/orders/instrument lookup/option chains, all 11 skills' intent-building, WebSocket streaming) — live-verified.
854
+ - Write path — `dhan_place_order`, `dhan_cancel_order`, `dhan_skill_square_off_all`, and `dhan_skill_square_off_position` all verified end-to-end through the MCP-gated path (instrument resolution, full risk pipeline, audit logging, real execution). Dhan's own sandbox never executes real fills (by design, per Dhan's docs), so fills were exercised against `simulators/paper_exchange`'s real matching engine via a throwaway Dhan-API-compatible adapter. Found and fixed a real bug in the process: both square-off skills used `p[:net_quantity]`-style hash indexing on `Position` (which has no `[]` method), silently finding zero positions to close every time — invisible until a real position finally existed to test against.
855
+ - Risk checks that read portfolio state (`Concentration`, `PositionLimits`, `MaxLoss`) have only been observed against zero/low-balance accounts and a single simulated position — not a large multi-symbol portfolio.
856
+ - The sandbox environment's security-ID catalog is disconnected from the production instrument master — resolve IDs from sandbox order history, not `Instrument.find`, when testing against sandbox.
857
+
858
+ ---
859
+
474
860
  ## Best Practices
475
861
 
476
862
  - Keep `on(:tick)` handlers **non-blocking** — push heavy work to a queue/thread
@@ -54,7 +54,7 @@ If the token was **generated from Dhan Web** (not API key flow), you can refresh
54
54
 
55
55
  ```ruby
56
56
  # Returns a hash with API response (e.g. accessToken, expiryTime). Use the new token for subsequent requests.
57
- response = DhanHQ::Auth.renew_token(current_access_token, client_id)
57
+ response = DhanHQ::Auth.renew_token(access_token: current_access_token, client_id: client_id)
58
58
  new_token = response["accessToken"] || response[:accessToken]
59
59
  # Optional: response may include "expiryTime"
60
60
  ```
@@ -68,7 +68,7 @@ Example: refresh in provider and cache the result until near expiry:
68
68
  config.access_token_provider = lambda do
69
69
  stored = YourTokenStore.current
70
70
  if stored.nil? || stored.expired_soon?
71
- response = DhanHQ::Auth.renew_token(stored&.access_token || ENV["DHAN_ACCESS_TOKEN"], config.client_id)
71
+ response = DhanHQ::Auth.renew_token(access_token: stored&.access_token || ENV["DHAN_ACCESS_TOKEN"], client_id: config.client_id)
72
72
  YourTokenStore.update!(response["accessToken"], response["expiryTime"])
73
73
  stored = YourTokenStore.current
74
74
  end
@@ -219,6 +219,4 @@ Central service: configure_from_token_endpoint
219
219
 
220
220
  Fully automated: TOTP generate (Auth / Client#generate_access_token)
221
221
 
222
- Production-grade automation: enable_auto_token_management! (generate + renew)
223
-
224
- If you tell me your exact deployment style (single user box, multi-user SaaS, on-prem, etc.), I can tell you which one you should actually use and what to delete as overkill.
222
+ Production-grade automation: enable_auto_token_management! (generate + renew)
@@ -9,7 +9,7 @@ require 'dhan_hq'
9
9
  DhanHQ.configure_with_env
10
10
  ```
11
11
 
12
- `configure_with_env` reads `DHAN_CLIENT_ID` and `DHAN_ACCESS_TOKEN` from `ENV` and raises if either is missing.
12
+ `configure_with_env` reads `DHAN_CLIENT_ID` and `DHAN_ACCESS_TOKEN` from `ENV`. It does **not** raise if either is missing — they're simply left `nil`, and a `DhanHQ::AuthenticationError` (or similar) surfaces later, only when a request actually needs the token.
13
13
 
14
14
  ## Required Environment Variables
15
15
 
@@ -25,7 +25,8 @@ Set these _before_ calling `configure_with_env` to override defaults:
25
25
  | Variable | Default | Description |
26
26
  | -------------------------------- | ---------- | ------------------------------------------------------- |
27
27
  | `DHAN_LOG_LEVEL` | `INFO` | Logger verbosity (`DEBUG`, `INFO`, `WARN`, `ERROR`) |
28
- | `DHAN_BASE_URL` | Dhan prod | Point REST calls to a different API hostname |
28
+ | `DHAN_SANDBOX` | `false` | Set `"true"` to route REST calls to `https://sandbox.dhan.co/v2` instead of production. Note: Dhan's sandbox validates request/response plumbing only — it does not execute real order fills/matching. Placed orders stay `PENDING` indefinitely. See [ENDPOINTS_AND_SANDBOX.md](ENDPOINTS_AND_SANDBOX.md). |
29
+ | `DHAN_BASE_URL` | Dhan prod | Point REST calls to a different API hostname. Takes precedence over `DHAN_SANDBOX` only when explicitly set to something other than the production default. |
29
30
  | `DHAN_WS_VERSION` | latest | Pin WebSocket connections to a specific API version |
30
31
  | `DHAN_WS_ORDER_URL` | Dhan prod | Override the order update WebSocket endpoint |
31
32
  | `DHAN_WS_USER_TYPE` | `SELF` | Switch between `SELF` and `PARTNER` streaming modes |
@@ -17,8 +17,9 @@ Exchange segments for different markets and instruments.
17
17
  | `NSE_EQ` | `"NSE_EQ"` | NSE - Equity Cash | 1 |
18
18
  | `NSE_FNO` | `"NSE_FNO"` | NSE - Futures & Options | 2 |
19
19
  | `NSE_CURRENCY` | `"NSE_CURRENCY"` | NSE - Currency | 3 |
20
- | `BSE_EQ` | `"BSE_EQ"` | BSE - Equity Cash | 4 |
21
- | `MCX_COMM` | `"MCX_COMM"` | MCX - Commodity | 5 |
20
+ | `NSE_COMM` | `"NSE_COMM"` | NSE - Commodity | 4 |
21
+ | `BSE_EQ` | `"BSE_EQ"` | BSE - Equity Cash | 5 |
22
+ | `MCX_COMM` | `"MCX_COMM"` | MCX - Commodity | 6 |
22
23
  | `BSE_CURRENCY` | `"BSE_CURRENCY"` | BSE - Currency | 7 |
23
24
  | `BSE_FNO` | `"BSE_FNO"` | BSE - Futures & Options | 8 |
24
25
 
@@ -431,7 +432,7 @@ end
431
432
 
432
433
  ### Validation Helpers
433
434
 
434
- Consider adding validation helpers:
435
+ Already implemented in `lib/DhanHQ/constants.rb`:
435
436
 
436
437
  ```ruby
437
438
  module DhanHQ
@@ -439,7 +440,7 @@ module DhanHQ
439
440
  def self.valid?(module_name, value)
440
441
  const_get(module_name)::ALL.include?(value)
441
442
  end
442
-
443
+
443
444
  def self.all_for(module_name)
444
445
  const_get(module_name)::ALL
445
446
  end
@@ -457,10 +458,11 @@ DhanHQ::Constants.all_for(:OrderType) # => ["LIMIT", "MARKET", ...]
457
458
 
458
459
  | API Type | Per Second | Per Minute | Per Hour | Per Day |
459
460
  |----------|------------|------------|----------|---------|
460
- | Order APIs | 10 | 250 | 1,000 | 7,000 |
461
- | Data APIs | 5 | - | - | 100,000 |
461
+ | Order APIs | 10 | Unlimited | Unlimited | 100,000 |
462
+ | Data APIs | 5 | - | - | 7,000 |
462
463
  | Quote APIs | 1 | Unlimited | Unlimited | Unlimited |
463
464
  | Non Trading APIs | 20 | Unlimited | Unlimited | Unlimited |
465
+ | Option Chain | 1 per 3 sec | - | - | - |
464
466
 
465
467
  **Note:** Order modifications are capped at 25 modifications per order.
466
468
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Sandbox behavior
4
4
 
5
+ - **Sandbox does NOT execute real order fills/matching.** Confirmed both via Dhan's own docs ("the sandbox behaves like the live API but does not execute real orders") and empirically: a `MARKET` order placed on sandbox validates and returns a real `orderId`, but stays at `PENDING` indefinitely — it never transitions to `TRADED`, even after 24+ seconds of polling. Use sandbox to validate request/response plumbing (auth, payload shape, error codes), not to test fill, cancel, or square-off behavior.
5
6
  - **REST:** When `DhanHQ.configuration.sandbox == true` (or `ENV["DHAN_SANDBOX"]=true`), the client uses `https://sandbox.dhan.co/v2` as the base URL for **all** requests that go through `DhanHQ::Client`. Every wrapped REST endpoint listed below is therefore **sent** to the sandbox host when sandbox is enabled.
6
7
  - **Sandbox does NOT support WebSocket.** Order updates, market feed, and market depth are **production-only**. The gem always uses production WebSocket URLs regardless of the `sandbox` setting. There are no sandbox WebSocket endpoints in the Dhan v2 API; do not rely on sandbox for real-time streams.
7
8
  - **Auth endpoints** (`DhanHQ::Auth`) do **not** use sandbox. They always call:
@@ -320,24 +320,19 @@ This ensures safe usage in multi-threaded applications.
320
320
 
321
321
  ## Testing
322
322
 
323
- For comprehensive testing examples and interactive console helpers, see the [Testing Guide](TESTING_GUIDE.md). The guide includes:
323
+ For comprehensive testing examples, see the [Testing Guide](TESTING_GUIDE.md). It covers:
324
324
 
325
325
  - **Order Update WebSocket Testing**: Complete examples for all order update features
326
326
  - **Event Handler Testing**: Examples for all event types
327
327
  - **Order State Management**: Testing order tracking and querying
328
- - **Interactive Console Helpers**: Load `bin/test_helpers.rb` for quick test functions
329
328
 
330
329
  **Quick start in console:**
331
330
  ```ruby
332
331
  # Start console
333
332
  bin/console
334
333
 
335
- # Load test helpers
336
- load 'bin/test_helpers.rb'
337
-
338
- # Test order WebSocket
339
- test_order_websocket(10) # Test order updates for 10 seconds
340
-
341
- # Monitor specific order
342
- monitor_order("112111182045") # Monitor order by ID
334
+ # Test order WebSocket for 10 seconds
335
+ client = DhanHQ::WS::Orders.connect { |update| puts update.inspect }
336
+ sleep 10
337
+ client.stop
343
338
  ```
@@ -122,7 +122,7 @@ end
122
122
 
123
123
  When the API returns 401 or token-expired (error code 807) and `access_token_provider` is set, the client retries the request **once** with a fresh token from the provider. `on_token_expired` is called before that retry so you can refresh your store if needed.
124
124
 
125
- **RenewToken (web-generated tokens only):** If the token was generated from Dhan Web (24h validity), you can refresh it with `DhanHQ::Auth.renew_token(access_token, client_id)` and store the result; use it in your provider or call it from `on_token_expired`. The gem does **not** implement API key/secret or Partner consent flows—implement those in your app and pass the token to the gem. See [docs/AUTHENTICATION.md](AUTHENTICATION.md).
125
+ **RenewToken (web-generated tokens only):** If the token was generated from Dhan Web (24h validity), you can refresh it with `DhanHQ::Auth.renew_token(access_token: access_token, client_id: client_id)` (keyword arguments) and store the result; use it in your provider or call it from `on_token_expired`. The gem does **not** implement API key/secret or Partner consent flows—implement those in your app and pass the token to the gem. See [docs/AUTHENTICATION.md](AUTHENTICATION.md).
126
126
 
127
127
  ## 3. Build service objects for REST flows
128
128
 
@@ -148,8 +148,9 @@ When you push a tag (e.g., `v2.1.12`), the **Release** workflow (`.github/workfl
148
148
  2. ✅ **Generate OTP code** from secret `RUBYGEMS_OTP_SECRET`
149
149
  3. ✅ **Build gem** using `gem build DhanHQ.gemspec`
150
150
  4. ✅ **Push to RubyGems** with OTP using `GEM_HOST_API_KEY` (from secret `RUBYGEMS_API_KEY`)
151
+ 5. ✅ **Attach gem to GitHub Release** — uploads `DhanHQ-<version>.gem` as a downloadable asset on the GitHub Release for the tag, creating the release (with auto-generated notes) if it doesn't already exist, or uploading to it if it does
151
152
 
152
- Tests run on push/PR via the main CI workflow; the release job does not run tests. No GitHub Release is created (tag-only publish, same pattern as ollama-client).
153
+ Tests run on push/PR via the main CI workflow; the release job does not run tests. A GitHub Release page is created/updated with the built `.gem` attached as a downloadable asset (verified working on the real v3.1.0 release: https://github.com/shubhamtaywade82/dhanhq-client/releases/tag/v3.1.0).
153
154
 
154
155
  **No manual intervention needed!** ⚡
155
156
 
@@ -286,6 +287,16 @@ on:
286
287
  gem push "DhanHQ-${gem_version}.gem" --otp "$otp_code"
287
288
  ```
288
289
 
290
+ 5. **Attach gem to GitHub Release**
291
+ ```bash
292
+ # Creates the release (with --generate-notes) if it doesn't exist yet, else uploads to it
293
+ if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
294
+ gh release upload "$tag" "$gem_file" --repo "$GITHUB_REPOSITORY" --clobber
295
+ else
296
+ gh release create "$tag" "$gem_file" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes
297
+ fi
298
+ ```
299
+
289
300
  ### Security Features
290
301
 
291
302
  - 🔒 **MFA Required** - `rubygems_mfa_required = "true"` in gemspec
@@ -476,7 +487,7 @@ The old workflow (`.github/workflows/main.yml`):
476
487
  ✅ **MFA secured:** OTP authentication on every release
477
488
  ✅ **Production ready:** Battle-tested release workflow
478
489
  ✅ **Tests integrated:** Ensures quality before release
479
- ✅ **Tag-only release:** Same pattern as ollama-client; push tag `v*` to publish
490
+ ✅ **Tag-triggered release:** push tag `v*` to publish to RubyGems and attach the built gem to a GitHub Release
480
491
 
481
492
  Questions or issues? Check the [Troubleshooting](#troubleshooting) section above.
482
493
 
@@ -996,8 +996,8 @@ class MarketDataCLI
996
996
  volume: full[:vol],
997
997
  day_high: full[:day_high],
998
998
  day_low: full[:day_low],
999
- open: full[:open],
1000
- close: full[:close],
999
+ open: full[:day_open],
1000
+ close: full[:day_close],
1001
1001
  timestamp: timestamp.iso8601
1002
1002
  }
1003
1003