DhanHQ 3.0.1 → 3.1.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +51 -0
  4. data/README.md +152 -4
  5. data/docs/CONSTANTS_REFERENCE.md +3 -2
  6. data/exe/dhanhq-mcp +7 -0
  7. data/lib/DhanHQ/agent/tool_registry.rb +51 -2
  8. data/lib/DhanHQ/concerns/order_audit.rb +43 -1
  9. data/lib/DhanHQ/constants.rb +3 -2
  10. data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
  11. data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
  12. data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
  13. data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
  14. data/lib/DhanHQ/mcp/server.rb +172 -9
  15. data/lib/DhanHQ/models/instrument.rb +44 -14
  16. data/lib/DhanHQ/rate_limiter.rb +5 -3
  17. data/lib/DhanHQ/resources/alert_orders.rb +1 -0
  18. data/lib/DhanHQ/resources/forever_orders.rb +1 -0
  19. data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
  20. data/lib/DhanHQ/resources/orders.rb +2 -0
  21. data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
  22. data/lib/DhanHQ/resources/super_orders.rb +1 -0
  23. data/lib/DhanHQ/resources/twap_orders.rb +1 -0
  24. data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
  25. data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
  26. data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
  27. data/lib/DhanHQ/risk/pipeline.rb +8 -1
  28. data/lib/DhanHQ/skills/base.rb +54 -3
  29. data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
  30. data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
  31. data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
  32. data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
  33. data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
  34. data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
  35. data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
  36. data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
  37. data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
  38. data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
  39. data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
  40. data/lib/DhanHQ/version.rb +1 -1
  41. data/lib/dhan_hq.rb +47 -0
  42. data/skills/dhanhq-ruby/SKILL.md +174 -41
  43. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  44. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  45. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  46. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  47. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  48. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  49. data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
  50. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  51. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  52. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  53. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  54. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  55. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  56. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  57. data/skills/dhanhq-ruby/references/funds.md +67 -0
  58. data/skills/dhanhq-ruby/references/instruments.md +85 -0
  59. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  60. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  61. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  62. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  63. data/skills/dhanhq-ruby/references/orders.md +200 -6
  64. data/skills/dhanhq-ruby/references/portfolio.md +93 -0
  65. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  66. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  67. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  68. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  69. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  70. metadata +39 -3
  71. data/skills/dhanhq-ruby/references/market_data.md +0 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d9d117dd455fc2a006a98cf0532297688a247498f5a12e98fb609a355ffc195
4
- data.tar.gz: 2334b0e16f35ecc00f414739f3d40da1cac7640ede1b64079248b35e02cc0851
3
+ metadata.gz: aa2811b4faf1f45f3ddc18b5f0b1ae03f481aa30fe48f1b1be3b3a928f87c581
4
+ data.tar.gz: cd4829e129d27e6b939bedaac18828f44237674ef5c84135ce9a647c4af37bcc
5
5
  SHA512:
6
- metadata.gz: 577d961c10faa0381d7e4f17a835688c181457bc7ed2222755123dd4cb7735431c1652af5d7026420c977b330863d9576354cc65c44aa1d7bc9342c1e759cce3
7
- data.tar.gz: fc6ba43d2e0e37d8e87c19aa7b803330377aa7c38bff4f90c451192d410a94a9864ea887f90b8a5419c608e0ca57fcb8711bd462976568772318aee29abbcb45
6
+ metadata.gz: 2b8fedf28da427de98161f6d2288230dc6e34a112bbfb56bda85d6373f0d2489cdfa8c2427facf0dc98e729790b4ee2023f534888f1b84989885993e9272545a
7
+ data.tar.gz: 11500ff76de75b2f9b9205e7f5af1210940291fe05b5c53b5f40d4f91e1a8d1c56346e8adb65c1fb2e2a36509b0c80f198f9264d402be8ced0622057fb288d64
data/.rubocop.yml CHANGED
@@ -11,6 +11,8 @@ require:
11
11
  AllCops:
12
12
  TargetRubyVersion: 3.2
13
13
  NewCops: enable
14
+ Exclude:
15
+ - "skills/**/*"
14
16
 
15
17
  Style/StringLiterals:
16
18
  EnforcedStyle: double_quotes
data/CHANGELOG.md CHANGED
@@ -1,3 +1,54 @@
1
+ ## [3.1.0] - 2026-07-20
2
+
3
+ ### Added
4
+
5
+ - **MCP resources support** — 6 URI-addressable resources (`dhanhq://account/profile`, `funds`, `holdings`, `positions`, `orders`, `dhanhq://market/capabilities`) with `resources/list` and `resources/read`.
6
+ - **MCP prompts support** — 5 pre-built AI prompts (`portfolio_summary`, `market_analysis`, `risk_report`, `order_preview`, `suggest_strategy`) with `prompts/list` and `prompts/get`.
7
+ - **5 new builtin skills** — `covered_call`, `bull_put_spread`, `bear_call_spread`, `protective_put`, `straddle` (10 total).
8
+ - **3 new risk checks** — `PositionLimits` (max 20 concurrent positions), `Concentration` (max 25% per symbol), `MaxLoss` (daily loss limit, default ₹50,000).
9
+ - **Extended risk pipeline** — `DhanHQ::Risk::Pipeline` now includes `DAILY_CHECKS` constant and runs all new checks.
10
+ - **Risk pipeline wired into all order paths** — `Risk::Pipeline.run!` now fires before every order placement via the `OrderAudit` concern, covering Orders, SuperOrders, ForeverOrders, AlertOrders, TwapOrders, IcebergOrders, and PnL Exit. Instrument resolution failures are handled silently so transient lookup issues never block a valid order.
11
+ - **SDK + AI integration docs** — README sections for MCP Server, Skills System, and AI Integration.
12
+
13
+ ### Changed
14
+
15
+ - `DhanHQ::MCP::Server` now requires `dhan_hq/ai` for prompt generation.
16
+ - CI RuboCop step no longer uses `continue-on-error: true`.
17
+ - Spec path for risk check specs moved to `spec/dhan_hq/risk/checks/`.
18
+ - **`OrderAudit` concern extended** — new `run_risk_checks!(params)` method runs the risk pipeline before order placement, with `trade_type_for` mapping exchange segments to pipeline types. All 7 order resources call it in their `create`/`configure` methods.
19
+
20
+ ### Fixed
21
+
22
+ - Syntax error in MCP server spec (orphaned `if response["error"]` debug lines removed).
23
+ - `[]` method stub pattern in risk check specs (use `receive(:[])` instead of hash double syntax).
24
+ - `MaxLoss` spec test data corrected to trigger the daily loss limit correctly.
25
+ - All RSpec VerifiedDoubles and MultipleExpectations offenses resolved (0 RuboCop offenses).
26
+ - **Position model accessors in risk checks** — replaced `p[:net_quantity]`, `p[:unrealized_profit_loss]`, and `p[:trading_symbol]` hash indexing with real model accessors (`p.net_qty`, `p.unrealized_profit`, `p.trading_symbol`).
27
+ - **`ltp` access in all 9 skills** — `instrument.ltp` returns a Float, not a Hash; removed the `ltp[:ltp]` unwrapping pattern.
28
+ - **`market_analysis` prompt** — resolves symbol to integer security ID via `Instrument.find` before passing to `MarketFeed.quote`.
29
+ - **`OrderAudit#run_risk_checks!` resolved the wrong instrument** — called `Instrument.find(exchange_segment, security_id)`, but `find`'s second argument is a symbol name (e.g. `"RELIANCE"`), not a security ID. Every real order silently failed instrument resolution (`Instrument.find` returned `nil` for a numeric ID), which combined with the surrounding `rescue StandardError; nil` meant **risk checks never ran for any real order placed through any of the 7 order resources**, despite the "wired into all order paths" claim above. Fixed by switching to the new `Instrument.find_by_security_id`. Confirmed live against a real account: `find("NSE_EQ", "2885")` → `nil`; `find_by_security_id("NSE_EQ", "2885")` → resolves RELIANCE correctly.
30
+ - **MCP JSON-RPC compliance** — notifications (requests with no `id`) no longer receive a response; dispatch errors preserve the caller's request `id`; error codes now use the correct `-32700`/`-32601`/`-32602`/`-32603` instead of a single generic `-32000`; `protocolVersion` is negotiated against the client's request instead of hardcoded.
31
+ - **`dhan_place_order` MCP tool bypassed all risk checks** — now resolves the instrument via `find_by_security_id` and runs `Risk::Pipeline.run!` before calling `Order.place`, same as the resource-level fix above, gated by the same live-write policy checks.
32
+ - **Option chain shape mismatch in 8 of 11 skills** — `Instrument#option_chain` returns `{ last_price:, strikes: [{ strike:, call:, put: }] }` (nested Hash), but `iron_condor`, `straddle`, `strangle`, `buy_atm_call`, `covered_call`, `protective_put`, `bull_put_spread`, and `bear_call_spread` were written assuming a flat array of `{ strike:, option_type:, security_id: }` leg-hashes. Every one of these skills raised `NoMethodError`/`TypeError` against the real API; all specs passed anyway because their fixtures invented the same wrong shape. Confirmed broken live, fixed against real NIFTY/RELIANCE option chains (real security IDs verified), all fixtures rewritten to match reality (`spec/support/option_chain_helpers.rb`).
33
+ - **MCP tool call could hang indefinitely** — a rate-limited `tools/call` blocked the single-threaded stdio loop with no error surfaced. Added a 15s timeout (`DhanHQ::MCP::Server#tool_call_timeout`) that returns a structured error instead.
34
+ - **`dhan_skill_*` tool descriptions** showed the raw Ruby class name (e.g. `"DhanHQ::Skills::Builtin::IronCondor"`) instead of anything useful to an LLM client. Added a `description` class macro to `Skills::Base`; all 11 builtin skills now declare a human-readable one-liner. Verified live via `tools/list`.
35
+ - **`Instrument.find`/`.find_by_security_id` could hang for minutes and use gigabytes of RAM** — both built a full `Instrument` model object (each with ~52 `define_singleton_method` calls from `BaseModel#assign_attributes`) for *every row* in the segment before filtering. Confirmed live: a single `find_by_security_id("NSE_EQ", ...)` call ran 98.9% CPU, 4.2GB RSS, and was killed after 3.5 minutes without finishing — `NSE_EQ` has ~219,000 rows. Fixed both methods to scan raw CSV rows and instantiate only the matching one (~9–11s now, down from unbounded). Added dedicated `.find` spec coverage (previously none existed) including a regression guard asserting only one `Instrument` is ever constructed.
36
+ - **`correlation_id` accepted up to 30 characters in 4 order contracts** (`PlaceOrderContract`, `ForeverOrderContract`, `IcebergOrderContract`, `TwapOrderContract`), but Dhan's real API caps it at 25 and rejects the **entire order** with a generic, field-agnostic `DH-905` error if exceeded — giving no indication `correlation_id` was the problem. Confirmed live by bisecting the exact boundary (25 chars: success: 26 chars: `DH-905`) against a real sandbox order. All 4 contracts corrected to `max_size?: 25`.
37
+ - **`square_off_all`/`square_off_position` never actually found any positions to close** — `SquareOffAll#fetch_positions` called `p[:net_quantity]` on a `Position` model (no `[]` method defined), silently rescued to `0`, and filtered out every real position, always reporting zero exits. `SquareOffPosition#find_position` had the same pattern for `exchange_segment`/`trading_symbol`/`security_id`/`net_quantity`, unguarded by any rescue, so it would raise `NoMethodError` the moment a real position existed. Both bugs were invisible until this session, because no test — unit or live — had ever exercised these skills against a real non-empty `Position.all` result; the unit-test doubles stubbed the same wrong `net_quantity` attribute name the buggy code called. Fixed both to use the real model accessors (`p.net_qty`, `p.exchange_segment`, `p.trading_symbol`, `p.security_id`); fixed both specs' doubles to match. Confirmed live: built a Dhan-API-compatible translation adapter in front of `simulators/paper_exchange` (a real Rails order-matching/fills/positions simulator already in this workspace) so a real order could actually fill (Dhan's own sandbox never executes real fills — confirmed via its docs), producing a genuine non-zero position, then ran `dhan_skill_square_off_all` and `dhan_skill_square_off_position` through the full MCP-gated path against it: both correctly found the position and closed it (`net_qty` 4→0 and 2→0 respectively) with a real `SUCCESS` response.
38
+
39
+ ### Known Limitations
40
+
41
+ Verified live against a real Dhan account (live-scoped and sandbox), a real independent MCP client, and — for the two paths sandbox cannot simulate (real fills, real position closure) — a purpose-built Dhan-API-compatible adapter in front of `simulators/paper_exchange`'s real order-matching engine.
42
+
43
+ - **Live-tested, all of it, including the full write path**: core REST client, MCP protocol layer, all 11 skills' intent-building, WebSocket streaming, and — end to end — `dhan_place_order`, `dhan_cancel_order`, `dhan_skill_square_off_all`, and `dhan_skill_square_off_position`, each through the actual MCP-gated path (instrument resolution → full risk pipeline → audit log → real order execution). Dhan's sandbox itself only validates request/response plumbing and never executes a real fill (confirmed via Dhan's own docs: "the sandbox behaves like the live API but does not execute real orders") — fills were exercised against `simulators/paper_exchange`'s real matching engine via a throwaway adapter (not shipped, not committed) instead.
44
+ - **`Concentration`/`PositionLimits`/`MaxLoss` risk checks** have only been observed against zero-position, zero-or-low-balance accounts, and against `paper_exchange`'s simulated position (a single small equity position). Their math is unit-tested; behavior against a large, multi-symbol real portfolio has not been observed.
45
+ - **The sandbox environment's instrument/security-ID catalog appears disconnected from the production instrument master** — a real production TCS security ID (`532540`, resolved via `Instrument.find`) was rejected by the sandbox's matching engine (`DH-906: Transaction has Failed`), while an ID from a prior sandbox test (`11536`) validated successfully. Anyone testing against sandbox should resolve security IDs from sandbox-originated data (e.g. prior order history), not the live instrument master.
46
+ - **`release.yml`'s GitHub Release gem-asset upload** has not fired end-to-end — no tag has been pushed since the fix landed.
47
+ - **v3.1.0 is not tagged** — CHANGELOG reflects work in progress on `main`, not a cut release.
48
+ - **Nothing from this session is committed.**
49
+
50
+ ---
51
+
1
52
  ## [3.0.0] - 2026-05-19
2
53
 
3
54
  ### Breaking Changes
data/README.md CHANGED
@@ -421,13 +421,18 @@ Need initializers, service objects, ActionCable wiring, and background workers?
421
421
 
422
422
  These scripts are designed around user goals rather than API surfaces:
423
423
 
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 |
424
+ | Example | What it covers |
425
+ | ------- | --------------- |
426
+ | [examples/basic_trading_bot.rb](examples/basic_trading_bot.rb) | Trading bot scaffold with live-trading guard |
427
+ | [examples/comprehensive_websocket_examples.rb](examples/comprehensive_websocket_examples.rb) | WebSocket mode coverage and timeout handling |
428
428
  | [examples/options_watchlist.rb](examples/options_watchlist.rb) | Build a live options watchlist with index quotes and option-chain context |
429
429
  | [examples/market_feed_example.rb](examples/market_feed_example.rb) | Subscribe to major market indices over WebSocket |
430
+ | [examples/market_depth_example.rb](examples/market_depth_example.rb) | Market depth streaming example |
430
431
  | [examples/live_order_updates.rb](examples/live_order_updates.rb) | Track order lifecycle events in real time |
432
+ | [examples/order_update_example.rb](examples/order_update_example.rb) | Single-session order-update flow |
433
+ | [examples/portfolio_monitor.rb](examples/portfolio_monitor.rb) | Snapshot funds, holdings, and positions for a monitoring script |
434
+ | [examples/trading_fields_example.rb](examples/trading_fields_example.rb) | Dhan order-field mappings and constants example |
435
+ | [examples/instrument_finder_test.rb](examples/instrument_finder_test.rb) | Instrument search/resolution troubleshooting |
431
436
 
432
437
  For search-driven discovery and onboarding content, see:
433
438
 
@@ -471,6 +476,149 @@ For search-driven discovery and onboarding content, see:
471
476
 
472
477
  ---
473
478
 
479
+ ## MCP Server (AI Agent Integration)
480
+
481
+ 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.
482
+
483
+ ### Quick Start
484
+
485
+ ```bash
486
+ # 1. Configure credentials
487
+ export DHAN_CLIENT_ID="your_client_id"
488
+ export DHAN_ACCESS_TOKEN="your_access_token"
489
+
490
+ # 2. Start the server (stdio)
491
+ bundle exec dhanhq-mcp
492
+ ```
493
+
494
+ Claude Desktop config (`claude_desktop_config.json`):
495
+
496
+ ```json
497
+ {
498
+ "mcpServers": {
499
+ "dhanhq": {
500
+ "command": "bundle",
501
+ "args": ["exec", "dhanhq-mcp"],
502
+ "env": {
503
+ "DHAN_CLIENT_ID": "your_client_id",
504
+ "DHAN_ACCESS_TOKEN": "your_access_token"
505
+ }
506
+ }
507
+ }
508
+ }
509
+ ```
510
+
511
+ ### MCP Features
512
+
513
+ | Feature | Description |
514
+ | ------- | ----------- |
515
+ | **Tools** | 12 primitive tools (profile, funds, holdings, positions, order history, order preview/place/cancel, instruments, market feed) + 11 skill-derived tools (`dhan_skill_*` — one per builtin strategy in [Skills System](#skills-system) below) |
516
+ | **Resources** | 6 URI-addressable data endpoints: `dhanhq://account/profile`, `dhanhq://account/funds`, `dhanhq://account/holdings`, `dhanhq://account/positions`, `dhanhq://account/orders`, `dhanhq://market/capabilities` |
517
+ | **Prompts** | 5 pre-built AI prompts: `portfolio_summary`, `market_analysis`, `risk_report`, `order_preview`, `suggest_strategy` |
518
+
519
+ ### Security & Policy
520
+
521
+ ```ruby
522
+ # Read-only mode (no order placement)
523
+ DhanHQ::Agent::Policy.read_only
524
+
525
+ # Scope-based policy from env (DHANHQ_AGENT_SCOPES)
526
+ DhanHQ::Agent::Policy.from_env
527
+ ```
528
+
529
+ The policy engine respects `DHANHQ_MCP_ENABLE_WRITES` and `LIVE_TRADING` env vars. Write operations are blocked by default — explicit opt-in required.
530
+
531
+ ## Skills System
532
+
533
+ Skills are reusable, composable trading strategies. DhanHQ ships with **11 builtin skills** and a registry for discovery and invocation.
534
+
535
+ ### Builtin Skills
536
+
537
+ | Skill | Type | Description |
538
+ | ----- | ---- | ----------- |
539
+ | `buy_atm_call` | Single-leg | Buy ATM call option |
540
+ | `square_off_all` | Action | Square off all open positions |
541
+ | `square_off_position` | Action | Square off a specific position |
542
+ | `iron_condor` | Multi-leg | Sell OTM put + buy further OTM put + sell OTM call + buy further OTM call |
543
+ | `strangle` | Multi-leg | Buy OTM put + buy OTM call |
544
+ | `covered_call` | Multi-leg | Buy equity + sell OTM call |
545
+ | `bull_put_spread` | Multi-leg | Sell OTM put + buy further OTM put |
546
+ | `bear_call_spread` | Multi-leg | Sell OTM call + buy further OTM call |
547
+ | `protective_put` | Multi-leg | Buy equity + buy OTM put |
548
+ | `straddle` | Multi-leg | Buy ATM call + buy ATM put |
549
+ | `market_data_summarizer` | Read-only | Summarize technicals and/or option chain for a symbol |
550
+
551
+ ### Using Skills
552
+
553
+ ```ruby
554
+ # Register all builtin skills
555
+ DhanHQ::Skills::Registry.load_builtins
556
+
557
+ # Find a skill by name
558
+ skill = DhanHQ::Skills::Registry.find("covered_call")
559
+
560
+ # Invoke — returns an intent hash (trade_type, legs, risk metadata)
561
+ result = skill.call(symbol: "RELIANCE", expiry: "2026-06-25")
562
+ # => { intent: { trade_type: "COVERED_CALL", legs: [...], total_premium: ..., break_even: ..., note: "..." } }
563
+ ```
564
+
565
+ Skills return intent hashes (not executed trades), keeping a human-in-the-loop safety pattern.
566
+
567
+ ## AI Integration
568
+
569
+ DhanHQ provides prompt helpers and risk reporting for LLM-powered trading agents.
570
+
571
+ ### Prompt Helpers
572
+
573
+ ```ruby
574
+ require 'dhan_hq/ai'
575
+
576
+ # Portfolio summary for AI consumption
577
+ DhanHQ::AI::PromptHelpers.portfolio_summary
578
+ # => "Portfolio Summary\n━━━━━━━━━━━━━\nFunds: ₹1,00,000.00\n..."
579
+
580
+ # Risk report
581
+ DhanHQ::AI::PromptHelpers.risk_report
582
+ # => "🔴 RISK ALERT: 394.6% drawdown..."
583
+ ```
584
+
585
+ ### Risk Pipeline
586
+
587
+ ```ruby
588
+ # Run pre-trade risk checks
589
+ DhanHQ::Risk::Pipeline.run!(
590
+ instrument: instrument,
591
+ args: { quantity: 25, price: 24_500 },
592
+ type: :fno,
593
+ now: Time.now
594
+ )
595
+ ```
596
+
597
+ Available checks:
598
+ - **TradingPermission** — blocks instruments where trading is disabled (`buy_sell_indicator != "A"`)
599
+ - **AsmGsm** — blocks ASM/GSM restricted instruments
600
+ - **ProductSupport** — validates bracket/cover order support for the instrument
601
+ - **OrderType** — restricts to `MARKET`/`LIMIT` order types
602
+ - **Quantity** — max 10 units / ₹1,00,000 notional (an agent-safety limit, not a general trading cap)
603
+ - **MarketHours** — verifies market is open (9:15 AM–3:30 PM IST)
604
+ - **PositionLimits** — max 20 concurrent open positions
605
+ - **Concentration** — max 25% of available balance in a single symbol
606
+ - **Options** (options only) — index-only, requires stop loss + target + risk-reward
607
+ - **MaxLoss** (daily) — daily loss limit (default ₹50,000)
608
+
609
+ 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.
610
+
611
+ ### Known Limitations
612
+
613
+ 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:
614
+
615
+ - Read path (profile/funds/holdings/positions/orders/instrument lookup/option chains, all 11 skills' intent-building, WebSocket streaming) — live-verified.
616
+ - 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.
617
+ - 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.
618
+ - 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.
619
+
620
+ ---
621
+
474
622
  ## Best Practices
475
623
 
476
624
  - Keep `on(:tick)` handlers **non-blocking** — push heavy work to a queue/thread
@@ -457,10 +457,11 @@ DhanHQ::Constants.all_for(:OrderType) # => ["LIMIT", "MARKET", ...]
457
457
 
458
458
  | API Type | Per Second | Per Minute | Per Hour | Per Day |
459
459
  |----------|------------|------------|----------|---------|
460
- | Order APIs | 10 | 250 | 1,000 | 7,000 |
461
- | Data APIs | 5 | - | - | 100,000 |
460
+ | Order APIs | 10 | Unlimited | Unlimited | 100,000 |
461
+ | Data APIs | 5 | - | - | 7,000 |
462
462
  | Quote APIs | 1 | Unlimited | Unlimited | Unlimited |
463
463
  | Non Trading APIs | 20 | Unlimited | Unlimited | Unlimited |
464
+ | Option Chain | 1 per 3 sec | - | - | - |
464
465
 
465
466
  **Note:** Order modifications are capped at 25 modifications per order.
466
467
 
data/exe/dhanhq-mcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dhan_hq"
5
+ require "dhan_hq/mcp"
6
+
7
+ DhanHQ::MCP::Server.new.run
@@ -65,6 +65,10 @@ module DhanHQ
65
65
  end
66
66
 
67
67
  def build_tools
68
+ (primitive_tools + skill_tools).to_h { |tool_item| [tool_item.name, tool_item] }
69
+ end
70
+
71
+ def primitive_tools
68
72
  [
69
73
  tool("dhan_profile", "Fetch Dhan profile", "portfolio:read", "read_only",
70
74
  object_schema, profile_handler,
@@ -124,7 +128,31 @@ module DhanHQ
124
128
  cancel_schema, cancel_order_handler,
125
129
  version: "1.0.0",
126
130
  output_schema: { type: "object", properties: { order_id: { type: "string" }, status: { type: "string" } } })
127
- ].to_h { |tool_item| [tool_item.name, tool_item] }
131
+ ]
132
+ end
133
+
134
+ # Exposes each registered DhanHQ::Skills::Registry strategy as an MCP tool,
135
+ # gated by the risk/scope the skill class declares (see DhanHQ::Skills::Base).
136
+ def skill_tools
137
+ DhanHQ::Skills::Registry.list.map do |skill|
138
+ klass = DhanHQ::Skills::Registry.find(skill[:name])
139
+ tool("dhan_skill_#{skill[:name]}", skill[:description], klass.scope, klass.risk,
140
+ skill_input_schema(skill[:params]),
141
+ ->(arguments) { DhanHQ::Skills::Registry.call(skill[:name], arguments) },
142
+ version: "1.0.0")
143
+ end
144
+ end
145
+
146
+ def skill_input_schema(params)
147
+ properties = params.transform_values do |config|
148
+ { type: skill_param_type(config[:type]) }.tap { |h| h[:description] = config[:description] if config[:description] }
149
+ end
150
+ required = params.select { |_, config| config[:required] }.keys.map(&:to_s)
151
+ { type: "object", properties: properties, required: required, additionalProperties: false }
152
+ end
153
+
154
+ def skill_param_type(type)
155
+ { string: "string", integer: "integer", number: "number", boolean: "boolean" }.fetch(type.to_sym, "string")
128
156
  end
129
157
 
130
158
  # rubocop:disable Metrics/ParameterLists
@@ -228,7 +256,20 @@ module DhanHQ
228
256
 
229
257
  def preview_handler = ->(arguments) { OrderPreview.new(arguments).to_h }
230
258
 
231
- def place_order_handler = ->(arguments) { DhanHQ::Models::Order.place(arguments) }
259
+ def place_order_handler
260
+ lambda do |arguments|
261
+ instrument = DhanHQ::Models::Instrument.find_by_security_id(arguments[:exchange_segment], arguments[:security_id])
262
+ unless instrument
263
+ raise DhanHQ::RiskViolation,
264
+ "Cannot verify risk for unknown instrument: #{arguments[:exchange_segment]}:#{arguments[:security_id]}"
265
+ end
266
+
267
+ risk_type = instrument.instrument_type.to_s.start_with?("OPT") ? :options : :equity
268
+ DhanHQ::Risk::Pipeline.run!(instrument: instrument, args: stringify(arguments), type: risk_type)
269
+
270
+ DhanHQ::Models::Order.place(arguments)
271
+ end
272
+ end
232
273
 
233
274
  def cancel_order_handler
234
275
  lambda do |arguments|
@@ -244,6 +285,14 @@ module DhanHQ
244
285
  else value
245
286
  end
246
287
  end
288
+
289
+ def stringify(value)
290
+ case value
291
+ when Hash then value.each_with_object({}) { |(key, val), hash| hash[key.to_s] = stringify(val) }
292
+ when Array then value.map { |val| stringify(val) }
293
+ else value
294
+ end
295
+ end
247
296
  end
248
297
  # rubocop:enable Metrics/ModuleLength
249
298
  end
@@ -4,7 +4,8 @@ require_relative "../utils/network_inspector"
4
4
 
5
5
  module DhanHQ
6
6
  module Concerns
7
- # Shared behavior for order audit logging and live trading safety.
7
+ # Shared behavior for order audit logging, live trading safety, and
8
+ # pre-trade risk checks.
8
9
  #
9
10
  # Include this module in any Resource that submits, modifies, or cancels
10
11
  # orders on the Dhan API. It provides:
@@ -17,12 +18,18 @@ module DhanHQ
17
18
  # unless +ENV["LIVE_TRADING"]+ is +"true"+, preventing accidental order
18
19
  # placement from development machines.
19
20
  #
21
+ # - {#run_risk_checks!}: runs {DhanHQ::Risk::Pipeline} against the order
22
+ # params before execution. Catches unresolvable instruments and other
23
+ # edge cases silently so the risk layer never blocks a valid order due
24
+ # to a transient lookup failure.
25
+ #
20
26
  # @example Including in a resource
21
27
  # class MyOrders < BaseAPI
22
28
  # include DhanHQ::Concerns::OrderAudit
23
29
  #
24
30
  # def create(params)
25
31
  # ensure_live_trading!
32
+ # run_risk_checks!(params)
26
33
  # log_order_context("MY_ORDER_ATTEMPT", params)
27
34
  # post("", params: params)
28
35
  # end
@@ -39,6 +46,41 @@ module DhanHQ
39
46
  "Live trading is disabled. Set ENV[\"LIVE_TRADING\"]=\"true\" to enable order placement."
40
47
  end
41
48
 
49
+ # Runs the risk pipeline for the given order params.
50
+ #
51
+ # Extracts +security_id+ and +exchange_segment+ from the params,
52
+ # resolves the instrument, and calls {DhanHQ::Risk::Pipeline.run!}.
53
+ # If the instrument cannot be resolved the check is skipped silently
54
+ # rather than blocking the order.
55
+ def run_risk_checks!(params)
56
+ security_id = extract_param(params, :securityId, :security_id)
57
+ exchange_segment = extract_param(params, :exchangeSegment, :exchange_segment)
58
+ return unless security_id && exchange_segment
59
+
60
+ instrument = DhanHQ::Models::Instrument.find_by_security_id(exchange_segment, security_id)
61
+ return unless instrument
62
+
63
+ DhanHQ::Risk::Pipeline.run!(
64
+ instrument: instrument,
65
+ args: params,
66
+ type: trade_type_for(exchange_segment),
67
+ now: Time.now
68
+ )
69
+ rescue DhanHQ::RiskViolation
70
+ raise
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ # Maps an exchange segment string to a pipeline trade type.
76
+ def trade_type_for(exchange_segment)
77
+ case exchange_segment.to_s
78
+ when /^NSE_FNO/, /^BSE_FNO/, /^NSE_CURRENCY/, /^BSE_CURRENCY/ then :fno
79
+ when /^MCX/ then :commodity
80
+ else :equity
81
+ end
82
+ end
83
+
42
84
  # Emits a structured JSON log line with machine/network/correlation context.
43
85
  # Uses WARN level so it appears even when INFO is silenced.
44
86
  def log_order_context(event, params = {})
@@ -381,9 +381,10 @@ module DhanHQ
381
381
 
382
382
  # Public rate limits published in DhanHQ API documentation.
383
383
  module RateLimit
384
- ORDER_API = { per_second: 10, per_minute: 250, per_hour: 1000, per_day: 7000 }.freeze
385
- DATA_API = { per_second: 5, per_day: 100_000 }.freeze
384
+ ORDER_API = { per_second: 10, per_day: 100_000 }.freeze
385
+ DATA_API = { per_second: 5, per_day: 7_000 }.freeze
386
386
  QUOTE_API = { per_second: 1 }.freeze
387
+ OPTION_CHAIN = { per_second: 1.0 / 3 }.freeze
387
388
  NON_TRADING_API = { per_second: 20 }.freeze
388
389
  ORDER_MODIFICATIONS_PER_ORDER = 25
389
390
  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?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
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)
@@ -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?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
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)
@@ -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?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
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)
@@ -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?: 30, format?: /\A[a-zA-Z0-9 _-]*\z/)
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)