DhanHQ 3.2.0 → 3.3.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +23 -0
  3. data/CHANGELOG.md +74 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/GUIDE.md +16 -0
  6. data/README.md +54 -1
  7. data/docs/CONFIGURATION.md +38 -14
  8. data/docs/RELEASE_GUIDE.md +1 -1
  9. data/lib/DhanHQ/ai/prompt_helpers.rb +17 -7
  10. data/lib/DhanHQ/client.rb +53 -34
  11. data/lib/DhanHQ/concerns/bang_writes.rb +69 -0
  12. data/lib/DhanHQ/concerns/tracked_writes.rb +56 -0
  13. data/lib/DhanHQ/configuration.rb +20 -1
  14. data/lib/DhanHQ/core/base_model.rb +6 -13
  15. data/lib/DhanHQ/deprecation.rb +62 -0
  16. data/lib/DhanHQ/models/alert_order.rb +8 -1
  17. data/lib/DhanHQ/models/forever_order.rb +9 -0
  18. data/lib/DhanHQ/models/global_stocks/order.rb +9 -0
  19. data/lib/DhanHQ/models/iceberg_order.rb +9 -0
  20. data/lib/DhanHQ/models/multi_order.rb +7 -0
  21. data/lib/DhanHQ/models/order.rb +28 -0
  22. data/lib/DhanHQ/models/pnl_exit.rb +7 -0
  23. data/lib/DhanHQ/models/super_order.rb +9 -0
  24. data/lib/DhanHQ/models/twap_order.rb +9 -0
  25. data/lib/DhanHQ/risk/pipeline.rb +0 -2
  26. data/lib/DhanHQ/version.rb +1 -1
  27. data/lib/DhanHQ/write_result.rb +163 -0
  28. data/lib/dhan_hq.rb +9 -0
  29. data/skills/dhanhq-ruby/SKILL.md +208 -0
  30. data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
  31. data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
  32. data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
  33. data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
  34. data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
  35. data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
  36. data/skills/dhanhq-ruby/examples/order_management.rb +112 -0
  37. data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
  38. data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
  39. data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
  40. data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
  41. data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
  42. data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
  43. data/skills/dhanhq-ruby/references/error-codes.md +50 -0
  44. data/skills/dhanhq-ruby/references/funds.md +67 -0
  45. data/skills/dhanhq-ruby/references/instruments.md +91 -0
  46. data/skills/dhanhq-ruby/references/live-feed.md +83 -0
  47. data/skills/dhanhq-ruby/references/market-data.md +119 -0
  48. data/skills/dhanhq-ruby/references/option-chain.md +71 -0
  49. data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
  50. data/skills/dhanhq-ruby/references/orders.md +203 -0
  51. data/skills/dhanhq-ruby/references/portfolio.md +100 -0
  52. data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
  53. data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
  54. data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
  55. data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
  56. data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
  57. metadata +36 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 474564a108e478d3e2cc7bb0ad2b313a885b265ca3ece5091bd04dc1bb31b014
4
- data.tar.gz: ab2a03010eb2ca03f7e6c9a01011b1b4b84f805611f22c55f7c2c07af1a7e063
3
+ metadata.gz: 065ac08985790dc4172816430025c482f539b506445c84181f69bdb56d30f510
4
+ data.tar.gz: 8f545f2ac8416dfadfe4fc0a5216b4203d06eaf05e1b1094d14ee571e84de342
5
5
  SHA512:
6
- metadata.gz: 531c7d4fba8e753c2371569a47562ed32cd026c8a2828e28bb7b617f5f342bec870ace4fac6f95b4e122b360c164c42a65a34a90899e2e100b18e7cc594ffea0
7
- data.tar.gz: 82332825d24456beeb67b61836472c05047ff323c3826f2f8324ac06ae036c10e9324788a43e1e7dbdcc74fb60cdbc385aaf373d99d851bb6732c51f6bf7638b
6
+ metadata.gz: 27d821b96f67997c64f1a523c2a366736dabcea5175e621014e22c9b691f35835f008f3bacaefe6b26229d2de92a78d44719f1b5ebff3e288960f156f7192331
7
+ data.tar.gz: 76558d36116bbc9c2b024aacc0c6cb1c30640b777cf2cd0832cb6d8da9b142fd6aca350318489a3d13f848136c1089bcd1ef014a2a975c8f66bde1e7c8131d10
data/AGENTS.md ADDED
@@ -0,0 +1,23 @@
1
+ # AGENTS.md
2
+
3
+ ## Cursor Cloud specific instructions
4
+
5
+ This is a pure-Ruby gem (no Rails, no running servers). Development commands are documented in `CLAUDE.md`.
6
+
7
+ ### Quick reference
8
+
9
+ | Task | Command |
10
+ |------|---------|
11
+ | Install deps | `bundle install` |
12
+ | Tests | `bundle exec rspec` |
13
+ | Lint | `bundle exec rubocop` |
14
+ | Both | `bundle exec rake` |
15
+ | Single spec | `bundle exec rspec spec/path/to_spec.rb` |
16
+
17
+ ### Non-obvious caveats
18
+
19
+ - **Bundler 4.x required**: The lockfile was bundled with Bundler 4.0.6. Install with `sudo gem install bundler -v 4.0.6` if missing.
20
+ - **`libyaml-dev` must be installed**: The `psych` gem (transitive dep) needs `yaml.h`. Without `libyaml-dev`, `bundle install` fails on native extension build.
21
+ - **`vendor/bundle` path**: Gems are installed to `./vendor/bundle` via `.bundle/config` to avoid needing root write access to `/var/lib/gems`.
22
+ - **No real API calls in tests**: All HTTP interactions are stubbed with WebMock/VCR. No `DHAN_CLIENT_ID` or `DHAN_ACCESS_TOKEN` secrets are needed to run the test suite.
23
+ - **No services to start**: This is a library gem. There is no web server, database, or background worker. Testing is purely `bundle exec rspec` / `bundle exec rubocop`.
data/CHANGELOG.md CHANGED
@@ -1,3 +1,77 @@
1
+ ## [3.3.0] - 2026-07-26
2
+
3
+ ### Added
4
+
5
+ - **Bang variants for every write method with a falsy failure contract** — `Order.place!`, `Order#modify!`/`#cancel!`/`#refresh!`, `SuperOrder.create!`/`#modify!`/`#cancel!`, `ForeverOrder.create!`, `IcebergOrder.create!`, `TwapOrder.create!`, `AlertOrder.create!`/`.modify!`, `PnlExit.configure!`/`.stop!`, `MultiOrder.place!`, `GlobalStocks::Order.place!`/`#modify!`/`#cancel!`.
6
+
7
+ Each raises `DhanHQ::OrderError` — which descends from `DhanHQ::Error`, so an existing `rescue DhanHQ::Error` handler still catches it — carrying whatever diagnostics the failure held. The non-bang methods are **untouched**: they return exactly what they returned before, so no existing caller changes behaviour.
8
+
9
+ This is step one of unifying the write return contracts. Today those contracts disagree: depending on the class and the failure, a rejected write comes back as `nil`, as `false`, or as a `DhanHQ::ErrorObject`, and `AlertOrder.modify` can return either of the first two from the same method. A caller cannot write one error branch. Unifying them outright would be a silent breaking change for dependent applications, because `nil` and `false` are falsy while `ErrorObject` is truthy — every `if result` failure branch in a dependent would quietly invert. So the migration is staged:
10
+
11
+ 1. **This release** — additive bang variants. Opt in per call site.
12
+ 2. **This release** — log a deprecation whenever a non-bang write returns a falsy failure, to find the remaining call sites from dependents' logs.
13
+ 3. **4.0.0** — non-bang methods return `ErrorObject` uniformly, gated on step 2 going quiet.
14
+
15
+ - **Deprecation notices for ambiguous write failures** (step two). When a non-bang write returns `nil`, `false` or a `DhanHQ::ErrorObject`, the SDK now logs once per call site:
16
+
17
+ ```
18
+ [DhanHQ] DEPRECATION: DhanHQ::Models::Order.place reported failure as nil. Write
19
+ methods return nil, false or a DhanHQ::ErrorObject inconsistently today and will all
20
+ return DhanHQ::ErrorObject in 4.0.0, which is truthy — an `if result` failure branch
21
+ will invert. Use DhanHQ::Models::Order.place! to get a DhanHQ::OrderError instead...
22
+ ```
23
+
24
+ The point is to surface the remaining call sites from dependent applications' logs before 4.0.0 changes any return value. Once per call site per process, not once per failure — a session that rejects hundreds of orders would otherwise produce noise that gets filtered out, defeating the purpose.
25
+
26
+ This layer only observes: it never alters a return value and never raises. It is silent on success, silent when reached through a bang variant (those callers have already migrated), and silent when you set `config.warn_on_ambiguous_write_failure = false` / `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false`. Defaults to on, because a notice nobody sees finds nothing.
27
+
28
+ - **`DhanHQ::Concerns::TrackedWrites`** — installs the observation. Uses `prepend` rather than `include`, since the write methods are defined directly on the model classes and an included module would sit behind them in the ancestor chain and never be reached.
29
+ - **`DhanHQ::Deprecation`** — once-per-key notice registry, thread-safe, with `warned_keys` and `reset!` for tests.
30
+ - **`DhanHQ::WriteResult`** — puts the knowledge of what a write failure looks like in one place (`failure?`, `success?`, `unwrap!`). `BaseModel#save!` now uses it instead of duplicating the same three-way check inline.
31
+ - **`DhanHQ::Concerns::BangWrites`** — generates the bang variants by delegating to their non-bang counterparts, so the two cannot drift: no duplicated request building, validation or logging. Generated as a module, so a hand-written `place!` can still override and call `super`.
32
+
33
+ ### Fixed
34
+
35
+ - **`DhanHQ::MCP` was unreachable on a bare `require "dhan_hq"`.** Same failure as the `DhanHQ::AI` fix earlier in this release: `mcp.rb` defines `DhanHQ::MCP` while Zeitwerk's inflector expected `DhanHQ::Mcp` from the filename, so `DhanHQ::MCP::Server` raised `NameError` unless something had already loaded the file by hand — which only `exe/dhanhq-mcp` and `lib/dhan_hq/mcp.rb` did. Found the same way the `AI` bug was: cross-referencing every `DhanHQ::` constant named in the docs against what actually resolves. Guarded by a new `spec/dhan_hq/zeitwerk_autoload_spec.rb`, which shells out to a subprocess so the check can't be satisfied by another spec having already loaded the file by hand.
36
+ - **`lib/DhanHQ/configuration.rb`'s doc comment referenced `DhanHQ::Models::Order.find_by_correlation_id`, which does not exist** — the real method is `.find_by_correlation` (no `_id` suffix). Caught during the same audit.
37
+ - A documentation pass across the gem ahead of this release, cross-checking every code example and referenced class/method against the actual codebase:
38
+ - `docs/CONFIGURATION.md`'s resource table named `DhanHQ::Models::Fund` (real class: `Funds`) and `DhanHQ::Models::Ledger` (real class: `LedgerEntry`), and listed `fund_limit`/`margin_calculator` as `Funds` methods — neither exists; the real methods are `Funds.fetch`/`.balance` and `Margin.calculate`/`.calculate_multi`. The table now also covers the resources 3.2.0/3.3.0 added (Iceberg/TWAP/Alert orders, Multi Order, P&L Exit, eDIS, Global Stocks) and lists the `!` write variants.
39
+ - `docs/CONFIGURATION.md` was missing `LIVE_TRADING`, `DHAN_DRY_RUN`, `DHAN_RETRY_WRITES`, `DHAN_AUTO_CORRELATION_ID`, `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE` and `DHAN_MARKET_DEPTH_LEVEL` entirely, and documented `DHAN_LOG_LEVEL` as if the library read it automatically — it doesn't; only the existing `## Logging` snippet wires it up.
40
+ - `skills/dhanhq-ruby/references/portfolio.md`'s eDIS section had the class name miscased (`EDIS` vs. `Edis`), called a nonexistent `.open_browser_for_tpin`, called `.inquiry` instead of `.inquire`, and accessed the (Hash) result via method calls instead of keys.
41
+ - `README.md` called `DhanHQ::Models::Fund.balance` — same class-name typo as above.
42
+ - `docs/RELEASE_GUIDE.md` claimed `Required Ruby: >= 3.1.0`; the gemspec has required `>= 3.2.0` since 3.0.0.
43
+ - `GUIDE.md` had no mention of the bang write variants added in this release; added a short section pointing to the README's fuller treatment.
44
+
45
+ ### Changed
46
+
47
+ - `BaseModel#save!`'s exception message is now `"<Class>#save failed: <details>"` rather than `"Failed to save the record: <details>"`. The exception class is unchanged (`DhanHQ::Error`), and nothing in the gem, specs or docs asserted the old text.
48
+
49
+ ## [3.2.1] - 2026-07-26
50
+
51
+ ### Fixed
52
+
53
+ - **Restored the Claude Agent Skill pack to the published gem.** Switching `spec.files` from a reject-list to an allowlist in 3.2.0 stripped 51 MB of junk (a 36 MB core dump and a 17 MB `diagram.html`), but it also dropped `skills/dhanhq-ruby/` — 28 files, 164 KB: `SKILL.md` and its trigger frontmatter, 12 reference guides, 11 examples and 4 helper scripts. That pack is product content, added deliberately in #41. No Ruby code loads it at runtime, so nothing broke and no spec noticed; users installing 3.2.0 simply stopped receiving it. `CODE_OF_CONDUCT.md` and `AGENTS.md` are restored alongside it.
54
+
55
+ Unaffected and still shipped throughout: the MCP server (`lib/DhanHQ/mcp/`, `exe/dhanhq-mcp`), the agent tool layer (all 32 tools), the Ruby skills classes (`lib/DhanHQ/skills/`, 11 builtin skills), the risk pipeline and the WebSocket subsystem.
56
+
57
+ Gem size: 3.1.0 was 5.9 MB, 3.2.0 was 292 KB, and this is 324 KB. The 20× reduction is real and almost entirely the core dump; the skill pack was collateral.
58
+ - **`DhanHQ::AI` was unreachable on a bare `require "dhan_hq"`.** The Zeitwerk inflector had no `ai → AI` acronym, so it looked for `DhanHQ::Ai` while `ai.rb` defines `DhanHQ::AI`; both spellings raised `NameError`. The documented `DhanHQ::AI::PromptHelpers` and `DhanHQ::AI::ContextBuilder` entry points therefore only worked if something had already loaded the file by hand, which only `mcp/server.rb` did. Found while writing the first specs for that module.
59
+
60
+ ### Added
61
+
62
+ - **`spec/dhan_hq/gem_packaging_spec.rb`** — pins both ends of what ships, since packaging has now failed in both directions. Asserts the entry point, both executables, RBS signatures, Rails initializer, MCP server, agent tool layer, Ruby skills and the Claude Skill pack are all present; asserts the known junk, any crash dump, the spec suite and development config are all absent; and caps the uncompressed payload at 3 MB so a stray binary fails loudly rather than silently adding megabytes to a release.
63
+ - First specs for `AI::PromptHelpers` (13 examples), covering portfolio summaries, the risk report's P&L arithmetic — summed across every position, counted only for open ones — and order confirmation rendering. Built on real model instances rather than doubles, because `BaseModel` defines attribute readers per instance and `instance_double` cannot verify them.
64
+ - Specs for the lazily-built connection (not opened on construction, memoised, rebuilt on a base-URL change) and for retry exhaustion (transport errors translated, SDK errors re-raised unchanged, reads still retried).
65
+
66
+ ### Changed
67
+
68
+ - **`Client#with_transient_retry` no longer duplicates its retry branch.** It had two rescue clauses running near-identical backoff-log-sleep-retry logic, differing only in what they raised once retries were spent. Collapsed into one clause, with an `exhausted_error` helper naming the difference: Faraday's transport errors are translated to `DhanHQ::NetworkError` (they are not part of this gem's hierarchy, so a caller rescuing `DhanHQ::Error` would otherwise miss them), while the gem's own errors are re-raised unchanged. Clears the `Lint/DuplicateBranch` pressure on this method.
69
+ - **`Client#initialize` no longer opens a connection.** It now establishes state and checks its one invariant; the Faraday connection is built on first use of `#connection` and rebuilt when the configured base URL changes. Behaviour is unchanged for callers — `#connection` is still public and still reflects a sandbox toggle mid-process — but constructing a client does no network setup.
70
+ - **`Naming/PredicateMethod` is now scoped by method name instead of by file.** Seven file-level exclusions replaced with `AllowedMethods: [cancel, modify, destroy]` plus `AllowBangMethods: true`.
71
+
72
+ These are commands that report whether they succeeded, not predicates — `cancel?` would read as "should I cancel?", which is worse than the name it replaces, so adding `?` aliases would have been the wrong fix. The unambiguous-failure path for them is the bang variant (`cancel!`, `modify!`). Scoping by name rather than by file also means a genuinely mis-named predicate elsewhere in those same files is still caught, and it surfaced a now-redundant inline `rubocop:disable` in `risk/pipeline.rb`.
73
+ - `AI::PromptHelpers.portfolio_summary` computed the open-position set twice (`count(&:open?)` then `select(&:open?)`); it now derives it once. Both it and `.risk_report` wrap their collections in `Array()`, so a nil from a failed fetch renders an empty section instead of raising from inside a prompt helper — `funds` was already guarded, the collections were not.
74
+
1
75
  ## [3.2.0] - 2026-07-25
2
76
 
3
77
  ### Added
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/GUIDE.md CHANGED
@@ -186,6 +186,22 @@ DhanHQ::Contracts::ModifyOrderContract.new.call(params).success?
186
186
  DhanHQ::Models::Order.resource.update("123", params)
187
187
  ```
188
188
 
189
+ ### Explicit Failures: Bang Variants
190
+
191
+ `place`, `#modify`, `#cancel` and their equivalents across every write model do not agree on
192
+ how they report failure — depending on the class, a rejected write comes back as `nil`,
193
+ `false`, or a `DhanHQ::ErrorObject`. Each has a `!` variant that raises `DhanHQ::OrderError`
194
+ instead, so one `rescue` handles every failure the same way:
195
+
196
+ ```ruby
197
+ order = DhanHQ::Models::Order.place!(params) # raises DhanHQ::OrderError on failure
198
+ order.cancel! # raises if the exchange did not cancel
199
+ ```
200
+
201
+ The non-bang methods are unchanged — adopting the bang variant is opt-in, per call site.
202
+ See the [README](README.md#explicit-failures-bang-variants) for the full list of `!` methods
203
+ and the deprecation notice that flags remaining non-bang call sites ahead of 4.0.0.
204
+
189
205
  ### Slicing Orders
190
206
 
191
207
  Use the same fields as placement, but the contract allows additional validity options (`GTC`, `GTD`). The model helper accepts snake_case parameters and handles camelCase conversion as part of validation:
data/README.md CHANGED
@@ -314,6 +314,59 @@ risk:
314
314
  DhanHQ.configure { |config| config.retry_non_idempotent_writes = true }
315
315
  ```
316
316
 
317
+ ### Explicit Failures: Bang Variants
318
+
319
+ Every write method has a `!` variant that raises instead of returning a falsy value:
320
+
321
+ ```ruby
322
+ order = DhanHQ::Models::Order.place!(params) # raises DhanHQ::OrderError on failure
323
+ order.cancel! # raises if the exchange did not cancel
324
+ ```
325
+
326
+ This exists because the non-bang methods do not agree on how to report failure — some
327
+ return `nil`, some `false`, some a `DhanHQ::ErrorObject` — so a caller cannot write one
328
+ error branch:
329
+
330
+ ```ruby
331
+ # Before: which falsy thing came back depends on which method and which failure
332
+ order = DhanHQ::Models::Order.place(params)
333
+ return unless order # nil on rejection
334
+
335
+ result = DhanHQ::Models::MultiOrder.place(legs)
336
+ return if result.is_a?(DhanHQ::ErrorObject) # truthy! `unless result` would not catch this
337
+ ```
338
+
339
+ `DhanHQ::OrderError` descends from `DhanHQ::Error`, so an existing `rescue DhanHQ::Error`
340
+ keeps working. The non-bang methods are unchanged, so adopting this is per call site:
341
+
342
+ ```ruby
343
+ begin
344
+ DhanHQ::Models::Order.place!(params)
345
+ rescue DhanHQ::OrderError => e
346
+ logger.error(e.message) # carries the API's diagnostics
347
+ end
348
+ ```
349
+
350
+ When a non-bang write reports failure, the SDK logs once per call site to help you find
351
+ what needs migrating before 4.0.0 changes the return value:
352
+
353
+ ```
354
+ [DhanHQ] DEPRECATION: DhanHQ::Models::Order.place reported failure as nil. ...
355
+ Use DhanHQ::Models::Order.place! to get a DhanHQ::OrderError instead, or set
356
+ config.warn_on_ambiguous_write_failure = false to silence this.
357
+ ```
358
+
359
+ It fires once per call site per process, never alters a return value, never raises, and
360
+ goes quiet once you switch that site to the bang variant. To silence it entirely:
361
+
362
+ ```ruby
363
+ DhanHQ.configure { |c| c.warn_on_ambiguous_write_failure = false } # or DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false
364
+ ```
365
+
366
+ Available on `Order`, `SuperOrder`, `ForeverOrder`, `IcebergOrder`, `TwapOrder`,
367
+ `AlertOrder`, `PnlExit`, `MultiOrder` and `GlobalStocks::Order`. The non-bang methods are
368
+ planned to converge on `ErrorObject` in 4.0.0 — see the CHANGELOG for the staged plan.
369
+
317
370
  ### Order Audit Logging
318
371
 
319
372
  Every order attempt (place, modify, slice) automatically logs a structured JSON line at WARN level:
@@ -374,7 +427,7 @@ order.cancel
374
427
  ```ruby
375
428
  DhanHQ::Models::Position.all
376
429
  DhanHQ::Models::Holding.all
377
- DhanHQ::Models::Fund.balance
430
+ DhanHQ::Models::Funds.balance
378
431
  ```
379
432
 
380
433
  ### Historical Data
@@ -24,7 +24,6 @@ Set these _before_ calling `configure_with_env` to override defaults:
24
24
 
25
25
  | Variable | Default | Description |
26
26
  | -------------------------------- | ---------- | ------------------------------------------------------- |
27
- | `DHAN_LOG_LEVEL` | `INFO` | Logger verbosity (`DEBUG`, `INFO`, `WARN`, `ERROR`) |
28
27
  | `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
28
  | `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. |
30
29
  | `DHAN_WS_VERSION` | latest | Pin WebSocket connections to a specific API version |
@@ -32,12 +31,27 @@ Set these _before_ calling `configure_with_env` to override defaults:
32
31
  | `DHAN_WS_USER_TYPE` | `SELF` | Switch between `SELF` and `PARTNER` streaming modes |
33
32
  | `DHAN_PARTNER_ID` | — | Required when `DHAN_WS_USER_TYPE=PARTNER` |
34
33
  | `DHAN_PARTNER_SECRET` | — | Required when `DHAN_WS_USER_TYPE=PARTNER` |
34
+ | `DHAN_MARKET_DEPTH_LEVEL` | `20` | WebSocket market depth levels to subscribe to |
35
35
  | `DHAN_CONNECT_TIMEOUT` | `10` | Connection timeout in seconds |
36
36
  | `DHAN_READ_TIMEOUT` | `30` | Read timeout in seconds |
37
37
  | `DHAN_WRITE_TIMEOUT` | `30` | Write timeout in seconds |
38
38
  | `DHAN_WS_MAX_TRACKED_ORDERS` | `10000` | Maximum orders to track in WebSocket |
39
39
  | `DHAN_WS_MAX_ORDER_AGE` | `604800` | Maximum order age in seconds before cleanup (7 days) |
40
40
 
41
+ `DHAN_LOG_LEVEL` is **not** read automatically — see [Logging](#logging) below for the one-line snippet that wires it up.
42
+
43
+ ## Behavior Flags
44
+
45
+ These change what a write request *does*, not just where it goes. All default to the safe/conservative behavior and are off unless set.
46
+
47
+ | Variable | Config attribute | Default | Effect |
48
+ | ------------------------------------- | ------------------------------------ | ------- | ------ |
49
+ | `LIVE_TRADING=true` | — | unset | **Required to place, modify, or cancel any real order, position exit, kill switch, EDIS, or P&L exit.** Without it, every write-path resource raises `DhanHQ::LiveTradingDisabledError` before making the request. This is the primary safety gate — see [ARCHITECTURE.md](../ARCHITECTURE.md) and the risk pipeline. |
50
+ | `DHAN_DRY_RUN=true` | `config.dry_run` | `false` | Suppresses every state-changing request, logs the payload as `DHAN_DRY_RUN`, and answers order placements with a simulated `DRYRUN-…` id so caller code paths still run to completion. Reads still hit the API. |
51
+ | `DHAN_RETRY_WRITES=true` | `config.retry_non_idempotent_writes` | `false` | Auto-retries a non-idempotent write (order placement, modify, cancel) after a transient failure (429, 5xx, timeout). Off by default because the API has no idempotency key — a timed-out POST may have already reached the exchange, and retrying it can place a duplicate order. |
52
+ | `DHAN_AUTO_CORRELATION_ID=true` | `config.auto_correlation_id` | `false` | Fills in a `correlationId` (`dhq-<hex>`) on order placements that lack one, so a timed-out placement can be reconciled via `GET /v2/orders/external/{correlation-id}`. Off by default because it changes the request body; an explicit correlation id is always preserved. |
53
+ | `DHAN_WARN_AMBIGUOUS_WRITE_FAILURE=false` | `config.warn_on_ambiguous_write_failure` | `true` (on) | Logs a once-per-call-site deprecation notice when a non-bang write method (`Order.place`, `#modify`, …) reports failure as `nil`, `false`, or a `DhanHQ::ErrorObject` — these disagree today and unify on `ErrorObject` in 4.0.0. Use the `!` variant (`place!`, `#modify!`) to get a raised `DhanHQ::OrderError` instead of the ambiguous return value. |
54
+
41
55
  ## `.env` File Setup
42
56
 
43
57
  Create a `.env` file in your project root:
@@ -95,16 +109,26 @@ For detailed authentication flows, see [AUTHENTICATION.md](AUTHENTICATION.md).
95
109
 
96
110
  ## Available Resources
97
111
 
98
- | Resource | Model | Actions |
99
- | ------------------------ | -------------------------------------- | --------------------------------------------------- |
100
- | Orders | `DhanHQ::Models::Order` | `find`, `all`, `where`, `place`, `update`, `cancel` |
101
- | Trades | `DhanHQ::Models::Trade` | `all`, `find_by_order_id` |
102
- | Forever Orders | `DhanHQ::Models::ForeverOrder` | `create`, `find`, `modify`, `cancel`, `all` |
103
- | Holdings | `DhanHQ::Models::Holding` | `all` |
104
- | Positions | `DhanHQ::Models::Position` | `all`, `find`, `exit!` |
105
- | Funds & Margin | `DhanHQ::Models::Fund` | `fund_limit`, `margin_calculator` |
106
- | Ledger | `DhanHQ::Models::Ledger` | `all` |
107
- | Market Feeds | `DhanHQ::Models::MarketFeed` | `ltp`, `ohlc`, `quote` |
108
- | Historical Data (Charts) | `DhanHQ::Models::HistoricalData` | `daily`, `intraday` |
109
- | Option Chain | `DhanHQ::Models::OptionChain` | `fetch`, `fetch_expiry_list` |
110
- | Super Orders | `DhanHQ::Models::SuperOrder` | `create`, `modify`, `cancel`, `all` |
112
+ | Resource | Model | Actions |
113
+ | ------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------ |
114
+ | Orders | `DhanHQ::Models::Order` | `place`, `find`, `all`, `where`, `#modify`, `#cancel`, `#destroy` (`!` variants raise) |
115
+ | Trades | `DhanHQ::Models::Trade` | `all`, `find_by_order_id` |
116
+ | Forever Orders | `DhanHQ::Models::ForeverOrder` | `create`, `find`, `all`, `#modify`, `#cancel` |
117
+ | Iceberg Orders | `DhanHQ::Models::IcebergOrder` | `create`, `find`, `all`, `#modify`, `#cancel` |
118
+ | TWAP Orders | `DhanHQ::Models::TwapOrder` | `create`, `find`, `all`, `#modify`, `#cancel` |
119
+ | Alert Orders | `DhanHQ::Models::AlertOrder` | `create`, `find`, `all`, `modify(alert_id, params)`, `#destroy` |
120
+ | Super Orders | `DhanHQ::Models::SuperOrder` | `create`, `all`, `#modify`, `#cancel(leg_name)` |
121
+ | Multi Order (basket) | `DhanHQ::Models::MultiOrder` | `place(orders, dhan_client_id:)` — up to 15 legs |
122
+ | P&L Exit | `DhanHQ::Models::PnlExit` | `configure`, `stop`, `status` |
123
+ | Holdings | `DhanHQ::Models::Holding` | `all` |
124
+ | Positions | `DhanHQ::Models::Position` | `all`, `active`, `#convert(params)`, `.exit_all!` |
125
+ | Funds | `DhanHQ::Models::Funds` | `fetch`, `balance` |
126
+ | Margin Calculator | `DhanHQ::Models::Margin` | `calculate(params)`, `calculate_multi(params)` |
127
+ | Ledger | `DhanHQ::Models::LedgerEntry` | `all(from_date:, to_date:)` |
128
+ | eDIS | `DhanHQ::Models::Edis` | `generate_tpin`, `generate_form`, `generate_bulk_form`, `inquire(isin:)` |
129
+ | Market Feeds | `DhanHQ::Models::MarketFeed` | `ltp`, `ohlc`, `quote` |
130
+ | Historical Data (Charts) | `DhanHQ::Models::HistoricalData` | `daily`, `intraday` |
131
+ | Option Chain | `DhanHQ::Models::OptionChain` | `fetch`, `fetch_expiry_list` |
132
+ | Global Stocks Orders | `DhanHQ::Models::GlobalStocks::Order` | `place`, `find`, `all`, `#cancel` — see [ARCHITECTURE.md](../ARCHITECTURE.md) |
133
+
134
+ `#method` denotes an instance method (called on a fetched or created record); everything else is a class method. Every write method above also has a `!` variant (`place!`, `#modify!`, `#cancel!`, …) that raises `DhanHQ::OrderError` instead of returning a falsy value or an `ErrorObject` — see the [CHANGELOG](../CHANGELOG.md) for the write-return-contract migration this is part of.
@@ -434,7 +434,7 @@ git push origin main v2.1.12
434
434
 
435
435
  - **Gem Name:** DhanHQ
436
436
  - **Current Version:** Check `lib/DhanHQ/version.rb`
437
- - **Required Ruby:** >= 3.1.0
437
+ - **Required Ruby:** >= 3.2.0
438
438
  - **License:** MIT
439
439
  - **Homepage:** https://github.com/shubhamtaywade82/dhanhq-client
440
440
 
@@ -41,14 +41,20 @@ module DhanHQ
41
41
  # @param funds [DhanHQ::Models::Funds] Funds data
42
42
  # @return [String] Portfolio summary
43
43
  def self.portfolio_summary(holdings:, positions:, funds:)
44
+ # Array() so a missing collection renders as an empty section instead of
45
+ # raising. `funds` was already guarded; these two were not, and a prompt
46
+ # helper failing hard on an empty portfolio is the wrong trade.
47
+ all_holdings = Array(holdings)
48
+ open_positions = Array(positions).select(&:open?)
49
+
44
50
  lines = ["=== Portfolio Summary ==="]
45
51
  lines << "Funds: #{funds.to_prompt}" if funds
46
52
  lines << ""
47
- lines << "Holdings (#{holdings.size}):"
48
- holdings.each { |h| lines << " #{h.to_prompt}" }
53
+ lines << "Holdings (#{all_holdings.size}):"
54
+ all_holdings.each { |holding| lines << " #{holding.to_prompt}" }
49
55
  lines << ""
50
- lines << "Open Positions (#{positions.count(&:open?)}):"
51
- positions.select(&:open?).each { |p| lines << " #{p.to_prompt}" }
56
+ lines << "Open Positions (#{open_positions.size}):"
57
+ open_positions.each { |position| lines << " #{position.to_prompt}" }
52
58
  lines.join("\n")
53
59
  end
54
60
 
@@ -95,13 +101,17 @@ module DhanHQ
95
101
  # @param risk_params [Hash] Risk parameters
96
102
  # @return [String] Risk report
97
103
  def self.risk_report(positions:, risk_params: {})
104
+ # P&L is summed across every position; only the count is restricted to open
105
+ # ones, since a closed position still contributed realised P&L today.
106
+ all_positions = Array(positions)
107
+
98
108
  lines = ["=== Risk Report ==="]
99
- total_unrealized = positions.sum { |p| p.unrealized_profit.to_f }
100
- total_realized = positions.sum { |p| p.realized_profit.to_f }
109
+ total_unrealized = all_positions.sum { |position| position.unrealized_profit.to_f }
110
+ total_realized = all_positions.sum { |position| position.realized_profit.to_f }
101
111
 
102
112
  lines << "Total Unrealized P&L: ₹#{total_unrealized.round(2)}"
103
113
  lines << "Total Realized P&L: ₹#{total_realized.round(2)}"
104
- lines << "Open Positions: #{positions.count(&:open?)}"
114
+ lines << "Open Positions: #{all_positions.count(&:open?)}"
105
115
 
106
116
  lines << "Max Drawdown: #{risk_params[:max_drawdown]}%" if risk_params[:max_drawdown]
107
117
 
data/lib/DhanHQ/client.rb CHANGED
@@ -25,31 +25,57 @@ module DhanHQ
25
25
  include DhanHQ::RequestHelper
26
26
  include DhanHQ::ResponseHelper
27
27
 
28
- # The Faraday connection object used for HTTP requests.
29
- #
30
- # @return [Faraday::Connection] The connection instance used for API requests.
31
- attr_reader :connection
28
+ # Transient failures raised by this gem, worth retrying as-is.
29
+ RETRYABLE_SDK_ERRORS = [
30
+ DhanHQ::RateLimitError,
31
+ DhanHQ::InternalServerError,
32
+ DhanHQ::NetworkError
33
+ ].freeze
34
+ private_constant :RETRYABLE_SDK_ERRORS
35
+
36
+ # Transport failures raised by Faraday. Also worth retrying, but they are not part
37
+ # of this gem's error hierarchy, so a caller rescuing DhanHQ::Error would miss
38
+ # them — they are translated on the way out. See {#exhausted_error}.
39
+ RETRYABLE_TRANSPORT_ERRORS = [
40
+ Faraday::TimeoutError,
41
+ Faraday::ConnectionFailed
42
+ ].freeze
43
+ private_constant :RETRYABLE_TRANSPORT_ERRORS
32
44
 
33
45
  attr_reader :token_manager
34
46
 
35
- # Initializes a new DhanHQ Client instance with a Faraday connection.
47
+ # Initializes a new DhanHQ Client.
48
+ #
49
+ # Establishes state and checks its one invariant; the HTTP connection is opened
50
+ # lazily on first use, so constructing a client does no network setup.
36
51
  #
37
52
  # @example Create a new client:
38
53
  # client = DhanHQ::Client.new(api_type: :order_api)
39
54
  #
40
55
  # @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
41
56
  # @return [DhanHQ::Client] A new client instance.
42
- # @raise [DhanHQ::Error] If configuration is invalid or rate limiter initialization fails
57
+ # @raise [DhanHQ::Error] If the rate limiter cannot be resolved for this API type.
43
58
  def initialize(api_type:)
44
59
  DhanHQ.ensure_configuration!
45
- # Use shared rate limiter instance per API type to ensure proper coordination
60
+ # Shared per API type, so separate clients coordinate against one limit.
46
61
  @rate_limiter = RateLimiter.for(api_type)
47
62
 
48
63
  raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
64
+ end
65
+
66
+ # The Faraday connection used for HTTP requests.
67
+ #
68
+ # Built on first use and rebuilt whenever the configured base URL changes — which
69
+ # happens when sandbox mode is toggled, or when a token endpoint hands back a
70
+ # different host mid-process.
71
+ #
72
+ # @return [Faraday::Connection]
73
+ def connection
74
+ current_url = DhanHQ.configuration.base_url
75
+ return @connection if @connection && @last_base_url == current_url
49
76
 
50
- # Store initial URL to detect changes
51
- @last_base_url = DhanHQ.configuration.base_url
52
- @connection = build_connection(@last_base_url)
77
+ @last_base_url = current_url
78
+ @connection = build_connection(current_url)
53
79
  end
54
80
 
55
81
  # Sends an HTTP request to the API with automatic retry for transient errors.
@@ -66,7 +92,6 @@ module DhanHQ
66
92
 
67
93
  @token_manager&.ensure_valid_token!
68
94
  @rate_limiter.throttle!
69
- refresh_connection!
70
95
 
71
96
  # A non-idempotent write is not safely retryable: the API has no idempotency
72
97
  # key, so a request that timed out may already have reached the exchange.
@@ -99,27 +124,13 @@ module DhanHQ
99
124
  attempt = 0
100
125
  begin
101
126
  yield
102
- rescue DhanHQ::RateLimitError, DhanHQ::InternalServerError, DhanHQ::NetworkError => e
103
- attempt += 1
104
- raise if attempt > retries
105
-
106
- backoff = calculate_backoff(attempt)
107
- DhanHQ.logger&.warn(
108
- "[DhanHQ::Client] Transient error (#{e.class}), retrying in #{backoff}s (attempt #{attempt}/#{retries})"
109
- )
110
- sleep(backoff)
111
- retry
112
- rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
127
+ rescue *RETRYABLE_SDK_ERRORS, *RETRYABLE_TRANSPORT_ERRORS => e
113
128
  attempt += 1
114
- if attempt > retries
115
- raise DhanHQ::NetworkError, "Request failed: #{e.message}" if retries.zero?
116
-
117
- raise DhanHQ::NetworkError, "Request failed after #{retries} retries: #{e.message}"
118
- end
129
+ raise exhausted_error(e, retries) if attempt > retries
119
130
 
120
131
  backoff = calculate_backoff(attempt)
121
132
  DhanHQ.logger&.warn(
122
- "[DhanHQ::Client] Network error (#{e.class}), retrying in #{backoff}s (attempt #{attempt}/#{retries})"
133
+ "[DhanHQ::Client] Transient failure (#{e.class}), retrying in #{backoff}s (attempt #{attempt}/#{retries})"
123
134
  )
124
135
  sleep(backoff)
125
136
  retry
@@ -253,12 +264,20 @@ module DhanHQ
253
264
  out
254
265
  end
255
266
 
256
- def refresh_connection!
257
- current_url = DhanHQ.configuration.base_url
258
- return if @last_base_url == current_url
259
-
260
- @last_base_url = current_url
261
- @connection = build_connection(current_url)
267
+ # The exception to raise once the retries are spent.
268
+ #
269
+ # Faraday's transport errors are translated to {DhanHQ::NetworkError}, so a caller
270
+ # rescuing DhanHQ::Error sees every failure this client can produce. This gem's own
271
+ # errors already belong to that hierarchy and are re-raised unchanged.
272
+ #
273
+ # @param error [StandardError] The failure that exhausted the retries.
274
+ # @param retries [Integer] How many retries were allowed.
275
+ # @return [StandardError] The exception to raise.
276
+ def exhausted_error(error, retries)
277
+ return error unless RETRYABLE_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
278
+
279
+ attempts = retries.zero? ? "" : " after #{retries} retries"
280
+ DhanHQ::NetworkError.new("Request failed#{attempts}: #{error.message}")
262
281
  end
263
282
 
264
283
  def build_connection(url)